diff options
Diffstat (limited to 'spec')
251 files changed, 11093 insertions, 1764 deletions
diff --git a/spec/controllers/admin/projects_controller_spec.rb b/spec/controllers/admin/projects_controller_spec.rb index d5a3c250f31..cc200b9fed9 100644 --- a/spec/controllers/admin/projects_controller_spec.rb +++ b/spec/controllers/admin/projects_controller_spec.rb @@ -31,5 +31,15 @@ describe Admin::ProjectsController do expect(response.body).not_to match(pending_delete_project.name) expect(response.body).to match(project.name) end + + it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do + get :index + + control_count = ActiveRecord::QueryRecorder.new { get :index }.count + + create(:project) + + expect { get :index }.not_to exceed_query_limit(control_count) + end end end diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb new file mode 100644 index 00000000000..f4c99ea4064 --- /dev/null +++ b/spec/controllers/concerns/send_file_upload_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe SendFileUpload do + let(:uploader_class) do + Class.new(GitlabUploader) do + include ObjectStorage::Concern + + storage_options Gitlab.config.uploads + + private + + # user/:id + def dynamic_segment + File.join(model.class.to_s.underscore, model.id.to_s) + end + end + end + + let(:controller_class) do + Class.new do + include SendFileUpload + end + end + + let(:object) { build_stubbed(:user) } + let(:uploader) { uploader_class.new(object, :file) } + + describe '#send_upload' do + let(:controller) { controller_class.new } + let(:temp_file) { Tempfile.new('test') } + + subject { controller.send_upload(uploader) } + + before do + FileUtils.touch(temp_file) + end + + after do + FileUtils.rm_f(temp_file) + end + + context 'when local file is used' do + before do + uploader.store!(temp_file) + end + + it 'sends a file' do + expect(controller).to receive(:send_file).with(uploader.path, anything) + + subject + end + end + + context 'when remote file is used' do + before do + stub_uploads_object_storage(uploader: uploader_class) + uploader.object_store = ObjectStorage::Store::REMOTE + uploader.store!(temp_file) + end + + context 'and proxying is enabled' do + before do + allow(Gitlab.config.uploads.object_store).to receive(:proxy_download) { true } + end + + it 'sends a file' do + headers = double + expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-url:/) + expect(controller).to receive(:headers) { headers } + expect(controller).to receive(:head).with(:ok) + + subject + end + end + + context 'and proxying is disabled' do + before do + allow(Gitlab.config.uploads.object_store).to receive(:proxy_download) { false } + end + + it 'sends a file' do + expect(controller).to receive(:redirect_to).with(/#{uploader.path}/) + + subject + end + end + end + end +end diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index c639ad32ec6..5f0e8c5eca9 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -3,72 +3,125 @@ require 'spec_helper' describe OmniauthCallbacksController do include LoginHelpers - let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: provider) } - let(:provider) { :github } + let(:user) { create(:omniauth_user, extern_uid: extern_uid, provider: provider) } before do - mock_auth_hash(provider.to_s, 'my-uid', user.email) + mock_auth_hash(provider.to_s, extern_uid, user.email) stub_omniauth_provider(provider, context: request) end - it 'allows sign in' do - post provider + context 'when the user is on the last sign in attempt' do + let(:extern_uid) { 'my-uid' } - expect(request.env['warden']).to be_authenticated - end + before do + user.update(failed_attempts: User.maximum_attempts.pred) + subject.response = ActionDispatch::Response.new + end - shared_context 'sign_up' do - let(:user) { double(email: 'new@example.com') } + context 'when using a form based provider' do + let(:provider) { :ldap } - before do - stub_omniauth_setting(block_auto_created_users: false) + it 'locks the user when sign in fails' do + allow(subject).to receive(:params).and_return(ActionController::Parameters.new(username: user.username)) + request.env['omniauth.error.strategy'] = OmniAuth::Strategies::LDAP.new(nil) + + subject.send(:failure) + + expect(user.reload).to be_access_locked + end end - end - context 'sign up' do - include_context 'sign_up' + context 'when using a button based provider' do + let(:provider) { :github } - it 'is allowed' do - post provider + it 'does not lock the user when sign in fails' do + request.env['omniauth.error.strategy'] = OmniAuth::Strategies::GitHub.new(nil) - expect(request.env['warden']).to be_authenticated + subject.send(:failure) + + expect(user.reload).not_to be_access_locked + end end end - context 'when OAuth is disabled' do - before do - stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - settings = Gitlab::CurrentSettings.current_application_settings - settings.update(disabled_oauth_sign_in_sources: [provider.to_s]) - end + context 'strategies' do + context 'github' do + let(:extern_uid) { 'my-uid' } + let(:provider) { :github } - it 'prevents login via POST' do - post provider + it 'allows sign in' do + post provider - expect(request.env['warden']).not_to be_authenticated - end + expect(request.env['warden']).to be_authenticated + end - it 'shows warning when attempting login' do - post provider + shared_context 'sign_up' do + let(:user) { double(email: 'new@example.com') } - expect(response).to redirect_to new_user_session_path - expect(flash[:alert]).to eq('Signing in using GitHub has been disabled') - end + before do + stub_omniauth_setting(block_auto_created_users: false) + end + end + + context 'sign up' do + include_context 'sign_up' + + it 'is allowed' do + post provider + + expect(request.env['warden']).to be_authenticated + end + end + + context 'when OAuth is disabled' do + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + settings = Gitlab::CurrentSettings.current_application_settings + settings.update(disabled_oauth_sign_in_sources: [provider.to_s]) + end + + it 'prevents login via POST' do + post provider + + expect(request.env['warden']).not_to be_authenticated + end - it 'allows linking the disabled provider' do - user.identities.destroy_all - sign_in(user) + it 'shows warning when attempting login' do + post provider - expect { post provider }.to change { user.reload.identities.count }.by(1) + expect(response).to redirect_to new_user_session_path + expect(flash[:alert]).to eq('Signing in using GitHub has been disabled') + end + + it 'allows linking the disabled provider' do + user.identities.destroy_all + sign_in(user) + + expect { post provider }.to change { user.reload.identities.count }.by(1) + end + + context 'sign up' do + include_context 'sign_up' + + it 'is prevented' do + post provider + + expect(request.env['warden']).not_to be_authenticated + end + end + end end - context 'sign up' do - include_context 'sign_up' + context 'auth0' do + let(:extern_uid) { '' } + let(:provider) { :auth0 } - it 'is prevented' do - post provider + it 'does not allow sign in without extern_uid' do + post 'auth0' expect(request.env['warden']).not_to be_authenticated + expect(response.status).to eq(302) + expect(controller).to set_flash[:alert].to('Wrong extern UID provided. Make sure Auth0 is configured correctly.') end end end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index 25a2e13fe1a..4ea6f869aa3 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -145,9 +145,23 @@ describe Projects::ArtifactsController do context 'when using local file storage' do it_behaves_like 'a valid file' do let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + let(:store) { ObjectStorage::Store::LOCAL } let(:archive_path) { JobArtifactUploader.root } end end + + context 'when using remote file storage' do + before do + stub_artifacts_object_storage + end + + it_behaves_like 'a valid file' do + let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: job) } + let!(:job) { create(:ci_build, :success, pipeline: pipeline) } + let(:store) { ObjectStorage::Store::REMOTE } + let(:archive_path) { 'https://' } + end + end end end diff --git a/spec/controllers/projects/ci/lints_controller_spec.rb b/spec/controllers/projects/ci/lints_controller_spec.rb new file mode 100644 index 00000000000..1249a5528a9 --- /dev/null +++ b/spec/controllers/projects/ci/lints_controller_spec.rb @@ -0,0 +1,123 @@ +require 'spec_helper' + +describe Projects::Ci::LintsController do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + describe 'GET #show' do + context 'with enough privileges' do + before do + project.add_developer(user) + + get :show, namespace_id: project.namespace, project_id: project + end + + it 'should be success' do + expect(response).to be_success + end + + it 'should render show page' do + expect(response).to render_template :show + end + + it 'should retrieve project' do + expect(assigns(:project)).to eq(project) + end + end + + context 'without enough privileges' do + before do + project.add_guest(user) + + get :show, namespace_id: project.namespace, project_id: project + end + + it 'should respond with 404' do + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe 'POST #create' do + let(:remote_file_path) { 'https://gitlab.com/gitlab-org/gitlab-ce/blob/1234/.gitlab-ci-1.yml' } + + let(:remote_file_content) do + <<~HEREDOC + before_script: + - apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs + - ruby -v + - which ruby + - gem install bundler --no-ri --no-rdoc + - bundle install --jobs $(nproc) "${FLAGS[@]}" + HEREDOC + end + + let(:content) do + <<~HEREDOC + include: + - #{remote_file_path} + + rubocop: + script: + - bundle exec rubocop + HEREDOC + end + + context 'with a valid gitlab-ci.yml' do + before do + WebMock.stub_request(:get, remote_file_path).to_return(body: remote_file_content) + project.add_developer(user) + + post :create, namespace_id: project.namespace, project_id: project, content: content + end + + it 'should be success' do + expect(response).to be_success + end + + it 'render show page' do + expect(response).to render_template :show + end + + it 'should retrieve project' do + expect(assigns(:project)).to eq(project) + end + end + + context 'with an invalid gitlab-ci.yml' do + let(:content) do + <<~HEREDOC + rubocop: + scriptt: + - bundle exec rubocop + HEREDOC + end + + before do + project.add_developer(user) + + post :create, namespace_id: project.namespace, project_id: project, content: content + end + + it 'should assign errors' do + expect(assigns[:error]).to eq('jobs:rubocop config contains unknown keys: scriptt') + end + end + + context 'without enough privileges' do + before do + project.add_guest(user) + + post :create, namespace_id: project.namespace, project_id: project, content: content + end + + it 'should respond with 404' do + expect(response).to have_gitlab_http_status(404) + end + end + end +end diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 15ce418d0d6..82b20e12850 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -18,7 +18,7 @@ describe Projects::ClustersController do context 'when project has one or more clusters' do let(:project) { create(:project) } let!(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } - let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, projects: [project]) } + let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, :production_environment, projects: [project]) } it 'lists available clusters' do go @@ -32,7 +32,7 @@ describe Projects::ClustersController do before do allow(Clusters::Cluster).to receive(:paginates_per).and_return(1) - create_list(:cluster, 2, :provided_by_gcp, projects: [project]) + create_list(:cluster, 2, :provided_by_gcp, :production_environment, projects: [project]) get :index, namespace_id: project.namespace, project_id: project, page: last_page end diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index f3e303bb0fe..31046c202e6 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -1,7 +1,9 @@ +# coding: utf-8 require 'spec_helper' describe Projects::JobsController do include ApiHelpers + include HttpIOHelpers let(:project) { create(:project, :public) } let(:pipeline) { create(:ci_pipeline, project: project) } @@ -203,6 +205,41 @@ describe Projects::JobsController do end end + context 'when trace artifact is in ObjectStorage' do + let!(:job) { create(:ci_build, :success, :trace_artifact, pipeline: pipeline) } + + before do + allow_any_instance_of(JobArtifactUploader).to receive(:file_storage?) { false } + allow_any_instance_of(JobArtifactUploader).to receive(:url) { remote_trace_url } + allow_any_instance_of(JobArtifactUploader).to receive(:size) { remote_trace_size } + end + + context 'when there are no network issues' do + before do + stub_remote_trace_206 + + get_trace + end + + it 'returns a trace' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['id']).to eq job.id + expect(json_response['status']).to eq job.status + expect(json_response['html']).to eq(job.trace.html) + end + end + + context 'when there is a network issue' do + before do + stub_remote_trace_500 + end + + it 'returns a trace' do + expect { get_trace }.to raise_error(Gitlab::Ci::Trace::HttpIO::FailedToGetChunkError) + end + end + end + def get_trace get :trace, namespace_id: project.namespace, project_id: project, @@ -446,14 +483,18 @@ describe Projects::JobsController do end describe 'GET raw' do - before do - get_raw + subject do + post :raw, namespace_id: project.namespace, + project_id: project, + id: job.id end context 'when job has a trace artifact' do let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) } it 'returns a trace' do + response = subject + expect(response).to have_gitlab_http_status(:ok) expect(response.content_type).to eq 'text/plain; charset=utf-8' expect(response.body).to eq job.job_artifacts_trace.open.read @@ -464,6 +505,8 @@ describe Projects::JobsController do let(:job) { create(:ci_build, :trace_live, pipeline: pipeline) } it 'send a trace file' do + response = subject + expect(response).to have_gitlab_http_status(:ok) expect(response.content_type).to eq 'text/plain; charset=utf-8' expect(response.body).to eq 'BUILD TRACE' @@ -474,14 +517,22 @@ describe Projects::JobsController do let(:job) { create(:ci_build, pipeline: pipeline) } it 'returns not_found' do + response = subject + expect(response).to have_gitlab_http_status(:not_found) end end - def get_raw - post :raw, namespace_id: project.namespace, - project_id: project, - id: job.id + context 'when the trace artifact is in ObjectStorage' do + let!(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) } + + before do + allow_any_instance_of(JobArtifactUploader).to receive(:file_storage?) { false } + end + + it 'redirect to the trace file url' do + expect(subject).to redirect_to(job.job_artifacts_trace.file.url) + end end end end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 306094f7ffb..5b2614163ff 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -20,14 +20,23 @@ describe Projects::MilestonesController do describe "#show" do render_views - def view_milestone - get :show, namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid + def view_milestone(options = {}) + params = { namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid } + get :show, params.merge(options) end it 'shows milestone page' do view_milestone expect(response).to have_gitlab_http_status(200) + expect(response.content_type).to eq 'text/html' + end + + it 'returns milestone json' do + view_milestone format: :json + + expect(response).to have_http_status(404) + expect(response.content_type).to eq 'application/json' end end diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb index 4705c50de7e..11f54eef531 100644 --- a/spec/controllers/projects/pages_controller_spec.rb +++ b/spec/controllers/projects/pages_controller_spec.rb @@ -65,4 +65,41 @@ describe Projects::PagesController do end end end + + describe 'PATCH update' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + project: { pages_https_only: false } + } + end + + let(:update_service) { double(execute: { status: :success }) } + + before do + allow(Projects::UpdateService).to receive(:new) { update_service } + end + + it 'returns 302 status' do + patch :update, request_params + + expect(response).to have_gitlab_http_status(:found) + end + + it 'redirects back to the pages settings' do + patch :update, request_params + + expect(response).to redirect_to(project_pages_path(project)) + end + + it 'calls the update service' do + expect(Projects::UpdateService) + .to receive(:new) + .with(project, user, request_params[:project]) + .and_return(update_service) + + patch :update, request_params + end + end end diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 83a3799e883..d4058a5c515 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -13,7 +13,7 @@ describe Projects::PagesDomainsController do end let(:pages_domain_params) do - build(:pages_domain, :with_certificate, :with_key, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain) + build(:pages_domain, domain: 'my.otherdomain.com').slice(:key, :certificate, :domain) end before do @@ -68,7 +68,7 @@ describe Projects::PagesDomainsController do end let(:pages_domain_params) do - attributes_for(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate) + attributes_for(:pages_domain).slice(:key, :certificate) end let(:params) do diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index 966ffdf6996..3506305f755 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -80,7 +80,7 @@ describe Projects::PipelineSchedulesController do context 'when variables_attributes has one variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] }) end @@ -101,7 +101,8 @@ describe Projects::PipelineSchedulesController do context 'when variables_attributes has two variables and duplicated' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }, + { key: 'AAA', secret_value: 'BBB123' }] }) end @@ -152,7 +153,7 @@ describe Projects::PipelineSchedulesController do context 'when params include one variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] }) end @@ -169,7 +170,8 @@ describe Projects::PipelineSchedulesController do context 'when params include two duplicated variables' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }, { key: 'AAA', value: 'BBB123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }, + { key: 'AAA', secret_value: 'BBB123' }] }) end @@ -194,7 +196,7 @@ describe Projects::PipelineSchedulesController do context 'when adds a new variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'AAA', value: 'AAA123' }] + variables_attributes: [{ key: 'AAA', secret_value: 'AAA123' }] }) end @@ -209,7 +211,7 @@ describe Projects::PipelineSchedulesController do context 'when adds a new duplicated variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ key: 'CCC', value: 'AAA123' }] + variables_attributes: [{ key: 'CCC', secret_value: 'AAA123' }] }) end @@ -224,7 +226,7 @@ describe Projects::PipelineSchedulesController do context 'when updates a variable' do let(:schedule) do basic_param.merge({ - variables_attributes: [{ id: pipeline_schedule_variable.id, value: 'new_value' }] + variables_attributes: [{ id: pipeline_schedule_variable.id, secret_value: 'new_value' }] }) end @@ -252,7 +254,7 @@ describe Projects::PipelineSchedulesController do let(:schedule) do basic_param.merge({ variables_attributes: [{ id: pipeline_schedule_variable.id, _destroy: true }, - { key: 'CCC', value: 'CCC123' }] + { key: 'CCC', secret_value: 'CCC123' }] }) end diff --git a/spec/controllers/projects/pipelines_settings_controller_spec.rb b/spec/controllers/projects/pipelines_settings_controller_spec.rb index 1cc488bef32..913b9bd804a 100644 --- a/spec/controllers/projects/pipelines_settings_controller_spec.rb +++ b/spec/controllers/projects/pipelines_settings_controller_spec.rb @@ -47,10 +47,32 @@ describe Projects::PipelinesSettingsController do expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(true) end - it 'queues a CreatePipelineWorker' do - expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + context 'when the project repository is empty' do + it 'sets a warning flash' do + expect(subject).to set_flash[:warning] + end - subject + it 'does not queue a CreatePipelineWorker' do + expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + subject + end + end + + context 'when the project repository is not empty' do + let(:project) { create(:project, :repository) } + + it 'sets a success flash' do + allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + expect(subject).to set_flash[:success] + end + + it 'queues a CreatePipelineWorker' do + expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + subject + end end end diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb index 80be135b5d8..096e29bc39f 100644 --- a/spec/controllers/projects/protected_branches_controller_spec.rb +++ b/spec/controllers/projects/protected_branches_controller_spec.rb @@ -1,6 +1,16 @@ require('spec_helper') describe Projects::ProtectedBranchesController do + let(:project) { create(:project, :repository) } + let(:protected_branch) { create(:protected_branch, project: project) } + let(:project_params) { { namespace_id: project.namespace.to_param, project_id: project } } + let(:base_params) { project_params.merge(id: protected_branch.id) } + let(:user) { create(:user) } + + before do + project.add_master(user) + end + describe "GET #index" do let(:project) { create(:project_empty_repo, :public) } @@ -8,4 +18,91 @@ describe Projects::ProtectedBranchesController do get(:index, namespace_id: project.namespace.to_param, project_id: project) end end + + describe "POST #create" do + let(:master_access_level) { [{ access_level: Gitlab::Access::MASTER }] } + let(:access_level_params) do + { merge_access_levels_attributes: master_access_level, + push_access_levels_attributes: master_access_level } + end + let(:create_params) { attributes_for(:protected_branch).merge(access_level_params) } + + before do + sign_in(user) + end + + it 'creates the protected branch rule' do + expect do + post(:create, project_params.merge(protected_branch: create_params)) + end.to change(ProtectedBranch, :count).by(1) + end + + context 'when a policy restricts rule deletion' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + allow(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents creation of the protected branch rule" do + post(:create, project_params.merge(protected_branch: create_params)) + + expect(ProtectedBranch.count).to eq 0 + end + end + end + + describe "PUT #update" do + let(:update_params) { { name: 'new_name' } } + + before do + sign_in(user) + end + + it 'updates the protected branch rule' do + put(:update, base_params.merge(protected_branch: update_params)) + + expect(protected_branch.reload.name).to eq('new_name') + expect(json_response["name"]).to eq('new_name') + end + + context 'when a policy restricts rule deletion' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + allow(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents update of the protected branch rule" do + old_name = protected_branch.name + + put(:update, base_params.merge(protected_branch: update_params)) + + expect(protected_branch.reload.name).to eq(old_name) + end + end + end + + describe "DELETE #destroy" do + before do + sign_in(user) + end + + it "deletes the protected branch rule" do + delete(:destroy, base_params) + + expect { ProtectedBranch.find(protected_branch.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'when a policy restricts rule deletion' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + allow(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents deletion of the protected branch rule" do + delete(:destroy, base_params) + + expect(response.status).to eq(403) + end + end + end end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index b7df42168e0..08e2ccf893a 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -8,10 +8,7 @@ describe Projects::RawController do let(:id) { 'master/README.md' } it 'delivers ASCII file' do - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project, - id: id) + get_show(public_project, id) expect(response).to have_gitlab_http_status(200) expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') @@ -25,10 +22,7 @@ describe Projects::RawController do let(:id) { 'master/files/images/6049019_460s.jpg' } it 'sets image content type header' do - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project, - id: id) + get_show(public_project, id) expect(response).to have_gitlab_http_status(200) expect(response.header['Content-Type']).to eq('image/jpeg') @@ -54,21 +48,40 @@ describe Projects::RawController do it 'serves the file' do expect(controller).to receive(:send_file).with("#{LfsObjectUploader.root}/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: 'lfs_object.iso', disposition: 'attachment') - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project, - id: id) + get_show(public_project, id) expect(response).to have_gitlab_http_status(200) end + + context 'and lfs uses object storage' do + before do + lfs_object.file = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") + lfs_object.save! + stub_lfs_object_storage + lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE) + end + + it 'responds with redirect to file' do + get_show(public_project, id) + + expect(response).to have_gitlab_http_status(302) + expect(response.location).to include(lfs_object.reload.file.path) + end + + it 'sets content disposition' do + get_show(public_project, id) + + file_uri = URI.parse(response.location) + params = CGI.parse(file_uri.query) + + expect(params["response-content-disposition"].first).to eq 'attachment;filename="lfs_object.iso"' + end + end end context 'when project does not have access' do it 'does not serve the file' do - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project, - id: id) + get_show(public_project, id) expect(response).to have_gitlab_http_status(404) end @@ -81,10 +94,7 @@ describe Projects::RawController do end it 'delivers ASCII file' do - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project, - id: id) + get_show(public_project, id) expect(response).to have_gitlab_http_status(200) expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') @@ -95,4 +105,10 @@ describe Projects::RawController do end end end + + def get_show(project, id) + get(:show, namespace_id: project.namespace.to_param, + project_id: project, + id: id) + end end diff --git a/spec/controllers/root_controller_spec.rb b/spec/controllers/root_controller_spec.rb index b32eb39b1fb..7688538a468 100644 --- a/spec/controllers/root_controller_spec.rb +++ b/spec/controllers/root_controller_spec.rb @@ -90,6 +90,30 @@ describe RootController do end end + context 'who has customized their dashboard setting for assigned issues' do + before do + user.dashboard = 'issues' + end + + it 'redirects to their assigned issues' do + get :index + + expect(response).to redirect_to issues_dashboard_path(assignee_id: user.id) + end + end + + context 'who has customized their dashboard setting for assigned merge requests' do + before do + user.dashboard = 'merge_requests' + end + + it 'redirects to their assigned merge requests' do + get :index + + expect(response).to redirect_to merge_requests_dashboard_path(assignee_id: user.id) + end + end + context 'who uses the default dashboard setting' do it 'renders the default dashboard' do get :index diff --git a/spec/factories/appearances.rb b/spec/factories/appearances.rb index 5f9c57c0c8d..18c7453bd1b 100644 --- a/spec/factories/appearances.rb +++ b/spec/factories/appearances.rb @@ -2,8 +2,21 @@ FactoryBot.define do factory :appearance do - title "MepMep" - description "This is my Community Edition instance" + title "GitLab Community Edition" + description "Open source software to collaborate on code" new_project_guidelines "Custom project guidelines" end + + trait :with_logo do + logo { fixture_file_upload('spec/fixtures/dk.png') } + end + + trait :with_header_logo do + header_logo { fixture_file_upload('spec/fixtures/dk.png') } + end + + trait :with_logos do + with_logo + with_header_logo + end end diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 8544d54ccaa..3d3287d8168 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -5,6 +5,10 @@ FactoryBot.define do job factory: :ci_build file_type :archive + trait :remote_store do + file_store JobArtifactUploader::Store::REMOTE + end + after :build do |artifact| artifact.project ||= artifact.job.project end diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb index 20d5580f0c2..98566f907f9 100644 --- a/spec/factories/clusters/clusters.rb +++ b/spec/factories/clusters/clusters.rb @@ -32,5 +32,9 @@ FactoryBot.define do trait :disabled do enabled false end + + trait :production_environment do + sequence(:environment_scope) { |n| "production#{n}/*" } + end end end diff --git a/spec/factories/internal_ids.rb b/spec/factories/internal_ids.rb new file mode 100644 index 00000000000..fbde07a391a --- /dev/null +++ b/spec/factories/internal_ids.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :internal_id do + project + usage :issues + last_value { project.issues.maximum(:iid) || 0 } + end +end diff --git a/spec/factories/lfs_objects.rb b/spec/factories/lfs_objects.rb index caaed4d5246..eaf3a4ed497 100644 --- a/spec/factories/lfs_objects.rb +++ b/spec/factories/lfs_objects.rb @@ -15,4 +15,8 @@ FactoryBot.define do trait :correct_oid do oid 'b804383982bb89b00e828e3f44c038cc991d3d1768009fc39ba8e2c081b9fb75' end + + trait :object_storage do + file_store { LfsObjectUploader::Store::REMOTE } + end end diff --git a/spec/factories/pages_domains.rb b/spec/factories/pages_domains.rb index 35b44e1c52e..20671da016e 100644 --- a/spec/factories/pages_domains.rb +++ b/spec/factories/pages_domains.rb @@ -4,25 +4,7 @@ FactoryBot.define do verified_at { Time.now } enabled_until { 1.week.from_now } - trait :disabled do - verified_at nil - enabled_until nil - end - - trait :unverified do - verified_at nil - end - - trait :reverify do - enabled_until { 1.hour.from_now } - end - - trait :expired do - enabled_until { 1.hour.ago } - end - - trait :with_certificate do - certificate '-----BEGIN CERTIFICATE----- + certificate '-----BEGIN CERTIFICATE----- MIICGzCCAYSgAwIBAgIBATANBgkqhkiG9w0BAQUFADAbMRkwFwYDVQQDExB0ZXN0 LWNlcnRpZmljYXRlMB4XDTE2MDIxMjE0MzIwMFoXDTIwMDQxMjE0MzIwMFowGzEZ MBcGA1UEAxMQdGVzdC1jZXJ0aWZpY2F0ZTCBnzANBgkqhkiG9w0BAQEFAAOBjQAw @@ -36,10 +18,8 @@ joZp2JHYvNlTPkRJ/J4TcXxBTJmArcQgTIuNoBtC+0A/SwdK4MfTCUY4vNWNdese 5A4K65Nb7Oh1AdQieTBHNXXCdyFsva9/ScfQGEl7p55a52jOPs0StPd7g64uvjlg YHi2yesCrOvVXt+lgPTd -----END CERTIFICATE-----' - end - trait :with_key do - key '-----BEGIN PRIVATE KEY----- + key '-----BEGIN PRIVATE KEY----- MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB @@ -55,6 +35,30 @@ EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx 63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi nNp/xedE1YxutQ== -----END PRIVATE KEY-----' + + trait :disabled do + verified_at nil + enabled_until nil + end + + trait :unverified do + verified_at nil + end + + trait :reverify do + enabled_until { 1.hour.from_now } + end + + trait :expired do + enabled_until { 1.hour.ago } + end + + trait :without_certificate do + certificate nil + end + + trait :without_key do + key nil end trait :with_missing_chain do diff --git a/spec/factories/redirect_routes.rb b/spec/factories/redirect_routes.rb index c29c81c5df9..774232d0b34 100644 --- a/spec/factories/redirect_routes.rb +++ b/spec/factories/redirect_routes.rb @@ -2,14 +2,5 @@ FactoryBot.define do factory :redirect_route do sequence(:path) { |n| "redirect#{n}" } source factory: :group - permanent false - - trait :permanent do - permanent true - end - - trait :temporary do - permanent false - end end end diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb index ff3a2a76acc..b45f6f30e40 100644 --- a/spec/factories/uploads.rb +++ b/spec/factories/uploads.rb @@ -5,6 +5,7 @@ FactoryBot.define do uploader "AvatarUploader" mount_point :avatar secret nil + store ObjectStorage::Store::LOCAL # we should build a mount agnostic upload by default transient do @@ -27,6 +28,10 @@ FactoryBot.define do secret SecureRandom.hex end + trait :object_storage do + store ObjectStorage::Store::REMOTE + end + trait :namespace_upload do model { build(:group) } path { File.join(secret, filename) } diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb index 9ea3cfa72c6..9946cc77d1d 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -55,14 +55,19 @@ feature 'Admin disables Git access protocol' do end def disable_http_protocol - visit admin_application_settings_path - find('#application_setting_enabled_git_access_protocol').find(:xpath, 'option[2]').select_option - click_on 'Save' + switch_git_protocol(2) end def disable_ssh_protocol + switch_git_protocol(3) + end + + def switch_git_protocol(value) visit admin_application_settings_path - find('#application_setting_enabled_git_access_protocol').find(:xpath, 'option[3]').select_option - click_on 'Save' + + page.within('.as-visibility-access') do + find('#application_setting_enabled_git_access_protocol').find(:xpath, "option[#{value}]").select_option + click_on 'Save' + end end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 39b213988f0..c89bc54cad4 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -10,18 +10,21 @@ feature 'Admin updates settings' do end scenario 'Change visibility settings' do - choose "application_setting_default_project_visibility_20" - click_button 'Save' + page.within('.as-visibility-access') do + choose "application_setting_default_project_visibility_20" + click_button 'Save changes' + end expect(page).to have_content "Application settings saved successfully" end scenario 'Uncheck all restricted visibility levels' do - find('#application_setting_visibility_level_0').set(false) - find('#application_setting_visibility_level_10').set(false) - find('#application_setting_visibility_level_20').set(false) - - click_button 'Save' + page.within('.as-visibility-access') do + find('#application_setting_visibility_level_0').set(false) + find('#application_setting_visibility_level_10').set(false) + find('#application_setting_visibility_level_20').set(false) + click_button 'Save changes' + end expect(page).to have_content "Application settings saved successfully" expect(find('#application_setting_visibility_level_0')).not_to be_checked @@ -29,34 +32,94 @@ feature 'Admin updates settings' do expect(find('#application_setting_visibility_level_20')).not_to be_checked end - scenario 'Change application settings' do - uncheck 'Gravatar enabled' - fill_in 'Home page URL', with: 'https://about.gitlab.com/' - fill_in 'Help page text', with: 'Example text' - check 'Hide marketing-related entries from help' - fill_in 'Support page URL', with: 'http://example.com/help' - uncheck 'Project export enabled' - click_button 'Save' + scenario 'Change Visibility and Access Controls' do + page.within('.as-visibility-access') do + uncheck 'Project export enabled' + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.project_export_enabled).to be_falsey + expect(page).to have_content "Application settings saved successfully" + end + + scenario 'Change Account and Limit Settings' do + page.within('.as-account-limit') do + uncheck 'Gravatar enabled' + click_button 'Save changes' + end expect(Gitlab::CurrentSettings.gravatar_enabled).to be_falsey + expect(page).to have_content "Application settings saved successfully" + end + + scenario 'Change Sign-in restrictions' do + page.within('.as-signin') do + fill_in 'Home page URL', with: 'https://about.gitlab.com/' + click_button 'Save changes' + end + expect(Gitlab::CurrentSettings.home_page_url).to eq "https://about.gitlab.com/" + expect(page).to have_content "Application settings saved successfully" + end + + scenario 'Change Help page' do + page.within('.as-help-page') do + fill_in 'Help page text', with: 'Example text' + check 'Hide marketing-related entries from help' + fill_in 'Support page URL', with: 'http://example.com/help' + click_button 'Save changes' + end + expect(Gitlab::CurrentSettings.help_page_text).to eq "Example text" expect(Gitlab::CurrentSettings.help_page_hide_commercial_content).to be_truthy expect(Gitlab::CurrentSettings.help_page_support_url).to eq "http://example.com/help" - expect(Gitlab::CurrentSettings.project_export_enabled).to be_falsey expect(page).to have_content "Application settings saved successfully" end - scenario 'Change AutoDevOps settings' do - check 'Enabled Auto DevOps (Beta) for projects by default' - fill_in 'Auto devops domain', with: 'domain.com' - click_button 'Save' + scenario 'Change Pages settings' do + page.within('.as-pages') do + fill_in 'Maximum size of pages (MB)', with: 15 + check 'Require users to prove ownership of custom domains' + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.max_pages_size).to eq 15 + expect(Gitlab::CurrentSettings.pages_domain_verification_enabled?).to be_truthy + expect(page).to have_content "Application settings saved successfully" + end + + scenario 'Change CI/CD settings' do + page.within('.as-ci-cd') do + check 'Enabled Auto DevOps (Beta) for projects by default' + fill_in 'Auto devops domain', with: 'domain.com' + click_button 'Save changes' + end expect(Gitlab::CurrentSettings.auto_devops_enabled?).to be true expect(Gitlab::CurrentSettings.auto_devops_domain).to eq('domain.com') expect(page).to have_content "Application settings saved successfully" end + scenario 'Change Influx settings' do + page.within('.as-influx') do + check 'Enable InfluxDB Metrics' + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.metrics_enabled?).to be true + expect(page).to have_content "Application settings saved successfully" + end + + scenario 'Change Prometheus settings' do + page.within('.as-prometheus') do + check 'Enable Prometheus Metrics' + click_button 'Save changes' + end + + expect(Gitlab::CurrentSettings.prometheus_metrics_enabled?).to be true + expect(page).to have_content "Application settings saved successfully" + end + scenario 'Change Slack Notifications Service template settings' do first(:link, 'Service Templates').click click_link 'Slack notifications' @@ -83,18 +146,22 @@ feature 'Admin updates settings' do context 'sign-in restrictions', :js do it 'de-activates oauth sign-in source' do - find('input#application_setting_enabled_oauth_sign_in_sources_[value=gitlab]').send_keys(:return) + page.within('.as-signin') do + find('input#application_setting_enabled_oauth_sign_in_sources_[value=gitlab]').send_keys(:return) - expect(find('.btn', text: 'GitLab.com')).not_to have_css('.active') + expect(find('.btn', text: 'GitLab.com')).not_to have_css('.active') + end end end scenario 'Change Keys settings' do - select 'Are forbidden', from: 'RSA SSH keys' - select 'Are allowed', from: 'DSA SSH keys' - select 'Must be at least 384 bits', from: 'ECDSA SSH keys' - select 'Are forbidden', from: 'ED25519 SSH keys' - click_on 'Save' + page.within('.as-visibility-access') do + select 'Are forbidden', from: 'RSA SSH keys' + select 'Are allowed', from: 'DSA SSH keys' + select 'Must be at least 384 bits', from: 'ECDSA SSH keys' + select 'Are forbidden', from: 'ED25519 SSH keys' + click_on 'Save changes' + end forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE.to_s diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index f82ed6300cc..4d897f09b57 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -20,7 +20,7 @@ describe 'Copy as GFM', :js do end # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. + # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM. # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. diff --git a/spec/features/ci_lint_spec.rb b/spec/features/projects/ci/lint_spec.rb index 220b934154e..313950072e7 100644 --- a/spec/features/ci_lint_spec.rb +++ b/spec/features/projects/ci/lint_spec.rb @@ -1,10 +1,14 @@ require 'spec_helper' describe 'CI Lint', :js do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + before do - sign_in(create(:user)) + project.add_developer(user) + sign_in(user) - visit ci_lint_path + visit project_ci_lint_path(project) find('#ci-editor') execute_script("ace.edit('ci-editor').setValue(#{yaml_content.to_json});") diff --git a/spec/features/projects/hook_logs/user_reads_log_spec.rb b/spec/features/projects/hook_logs/user_reads_log_spec.rb new file mode 100644 index 00000000000..18e975fa653 --- /dev/null +++ b/spec/features/projects/hook_logs/user_reads_log_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +feature 'Hook logs' do + given(:web_hook_log) { create(:web_hook_log, response_body: '<script>') } + given(:project) { web_hook_log.web_hook.project } + given(:user) { create(:user) } + + before do + project.add_master(user) + + sign_in(user) + end + + scenario 'user reads log without getting XSS' do + visit( + project_hook_hook_log_path( + project, web_hook_log.web_hook, web_hook_log)) + + expect(page).to have_content('<script>') + end +end diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index 233d2e67b9d..bdd49f731c7 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -40,11 +40,6 @@ feature 'Pages' do end context 'when support for external domains is disabled' do - before do - allow(Gitlab.config.pages).to receive(:external_http).and_return(nil) - allow(Gitlab.config.pages).to receive(:external_https).and_return(nil) - end - it 'renders message that support is disabled' do visit project_pages_path(project) @@ -52,7 +47,9 @@ feature 'Pages' do end end - context 'when pages are exposed on external HTTP address' do + context 'when pages are exposed on external HTTP address', :http_pages_enabled do + given(:project) { create(:project, pages_https_only: false) } + shared_examples 'adds new domain' do it 'adds new domain' do visit new_project_pages_domain_path(project) @@ -64,11 +61,6 @@ feature 'Pages' do end end - before do - allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) - allow(Gitlab.config.pages).to receive(:external_https).and_return(nil) - end - it 'allows to add new domain' do visit project_pages_path(project) @@ -80,13 +72,13 @@ feature 'Pages' do context 'when project in group namespace' do it_behaves_like 'adds new domain' do let(:group) { create :group } - let(:project) { create :project, namespace: group } + let(:project) { create(:project, namespace: group, pages_https_only: false) } end end context 'when pages domain is added' do before do - project.pages_domains.create!(domain: 'my.test.domain.com') + create(:pages_domain, project: project, domain: 'my.test.domain.com') visit new_project_pages_domain_path(project) end @@ -104,7 +96,7 @@ feature 'Pages' do end end - context 'when pages are exposed on external HTTPS address' do + context 'when pages are exposed on external HTTPS address', :https_pages_enabled do let(:certificate_pem) do <<~PEM -----BEGIN CERTIFICATE----- @@ -145,11 +137,6 @@ feature 'Pages' do KEY end - before do - allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) - allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443']) - end - it 'adds new domain with certificate' do visit new_project_pages_domain_path(project) @@ -163,7 +150,7 @@ feature 'Pages' do describe 'updating the certificate for an existing domain' do let!(:domain) do - create(:pages_domain, :with_key, :with_certificate, project: project) + create(:pages_domain, project: project) end it 'allows the certificate to be updated' do @@ -237,6 +224,70 @@ feature 'Pages' do it_behaves_like 'no pages deployed' end + describe 'HTTPS settings', :js, :https_pages_enabled do + background do + project.namespace.update(owner: user) + + allow_any_instance_of(Project).to receive(:pages_deployed?) { true } + end + + scenario 'tries to change the setting' do + visit project_pages_path(project) + expect(page).to have_content("Force domains with SSL certificates to use HTTPS") + + uncheck :project_pages_https_only + + click_button 'Save' + + expect(page).to have_text('Your changes have been saved') + expect(page).not_to have_checked_field('project_pages_https_only') + end + + context 'setting could not be updated' do + let(:service) { instance_double('Projects::UpdateService') } + + before do + allow(Projects::UpdateService).to receive(:new).and_return(service) + allow(service).to receive(:execute).and_return(status: :error) + end + + scenario 'tries to change the setting' do + visit project_pages_path(project) + + uncheck :project_pages_https_only + + click_button 'Save' + + expect(page).to have_text('Something went wrong on our end') + end + end + + context 'non-HTTPS domain exists' do + given(:project) { create(:project, pages_https_only: false) } + + before do + create(:pages_domain, :without_key, :without_certificate, project: project) + end + + scenario 'the setting is disabled' do + visit project_pages_path(project) + + expect(page).to have_field(:project_pages_https_only, disabled: true) + expect(page).not_to have_button('Save') + end + end + + context 'HTTPS pages are disabled', :https_pages_disabled do + scenario 'the setting is unavailable' do + visit project_pages_path(project) + + expect(page).not_to have_field(:project_pages_https_only) + expect(page).not_to have_content('Force domains with SSL certificates to use HTTPS') + expect(page).not_to have_button('Save') + end + end + end + describe 'Remove page' do context 'when user is the owner' do let(:project) { create :project, :repository } diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 65e24862d43..065d00d51d4 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -160,9 +160,9 @@ feature 'Pipeline Schedules', :js do click_link 'New schedule' fill_in_schedule_form all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') - all('[name="schedule[variables_attributes][][value]"]')[0].set('AAA123') + all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') - all('[name="schedule[variables_attributes][][value]"]')[1].set('BBB123') + all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') save_pipeline_schedule end diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb new file mode 100644 index 00000000000..d96c7e655ba --- /dev/null +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +feature 'Multi-file editor new directory', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates directory in current directory' do + find('.add-to-tree').click + + click_link('New directory') + + page.within('.modal') do + find('.form-control').set('folder name') + + click_button('Create directory') + end + + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal-dialog') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('folder name') + end +end diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb new file mode 100644 index 00000000000..a4cbd5cf766 --- /dev/null +++ b/spec/features/projects/tree/create_file_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +feature 'Multi-file editor new file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_path(project) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates file in current directory' do + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('file name') + end +end diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb index c8a17871508..c4b3fb9d171 100644 --- a/spec/features/projects/tree/tree_show_spec.rb +++ b/spec/features/projects/tree/tree_show_spec.rb @@ -25,4 +25,18 @@ feature 'Projects tree' do expect(page).to have_selector('.label-lfs', text: 'LFS') end end + + context 'web IDE', :js do + before do + visit project_tree_path(project, File.join('master', 'bar')) + + click_link 'Web IDE' + + find('.ide-file-list') + end + + it 'opens folder in IDE' do + expect(page).to have_selector('.is-open', text: 'bar') + end + end end diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb new file mode 100644 index 00000000000..8e53ae15700 --- /dev/null +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +feature 'Multi-file editor upload file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') } + let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'uploads text file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', txt_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt') + expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline)) + end + + it 'uploads image file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', img_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'dk.png') + expect(page).not_to have_selector('.monaco-editor') + end +end diff --git a/spec/features/read_only_spec.rb b/spec/features/read_only_spec.rb new file mode 100644 index 00000000000..8bfaf558466 --- /dev/null +++ b/spec/features/read_only_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +describe 'read-only message' do + set(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'shows read-only banner when database is read-only' do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + + visit root_dashboard_path + + expect(page).to have_content('You are on a read-only GitLab instance.') + end + + it 'does not show read-only banner when database is able to read-write' do + allow(Gitlab::Database).to receive(:read_only?).and_return(false) + + visit root_dashboard_path + + expect(page).not_to have_content('You are on a read-only GitLab instance.') + end +end diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb index 975c157bcf5..e069c2fddd1 100644 --- a/spec/features/user_can_display_performance_bar_spec.rb +++ b/spec/features/user_can_display_performance_bar_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe 'User can display performance bar', :js do shared_examples 'performance bar cannot be displayed' do it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end context 'when user press `pb`' do @@ -12,14 +12,14 @@ describe 'User can display performance bar', :js do end it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end end end shared_examples 'performance bar can be displayed' do it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end context 'when user press `pb`' do @@ -28,7 +28,7 @@ describe 'User can display performance bar', :js do end it 'shows the performance bar' do - expect(page).to have_css('#peek') + expect(page).to have_css('#js-peek') end end end @@ -41,7 +41,7 @@ describe 'User can display performance bar', :js do it 'shows the performance bar by default' do refresh # Because we're stubbing Rails.env after the 1st visit to root_path - expect(page).to have_css('#peek') + expect(page).to have_css('#js-peek') end end diff --git a/spec/finders/clusters_finder_spec.rb b/spec/finders/clusters_finder_spec.rb index c10efac2432..da529e0670f 100644 --- a/spec/finders/clusters_finder_spec.rb +++ b/spec/finders/clusters_finder_spec.rb @@ -6,7 +6,7 @@ describe ClustersFinder do describe '#execute' do let(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } - let(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, projects: [project]) } + let(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, :production_environment, projects: [project]) } subject { described_class.new(project, user, scope).execute } diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index e2a0c4322ff..c9d2ec8a4ae 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -21,7 +21,9 @@ describe PreferencesHelper do ["Your Projects' Activity", 'project_activity'], ["Starred Projects' Activity", 'starred_project_activity'], ["Your Groups", 'groups'], - ["Your Todos", 'todos'] + ["Your Todos", 'todos'], + ["Assigned Issues", 'issues'], + ["Assigned Merge Requests", 'merge_requests'] ] end end diff --git a/spec/initializers/6_validations_spec.rb b/spec/initializers/6_validations_spec.rb index 83283f03940..1dc307ea922 100644 --- a/spec/initializers/6_validations_spec.rb +++ b/spec/initializers/6_validations_spec.rb @@ -15,7 +15,7 @@ describe '6_validations' do describe 'validate_storages_config' do context 'with correct settings' do before do - mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' }) + mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/d')) end it 'passes through' do @@ -25,7 +25,7 @@ describe '6_validations' do context 'when one of the settings is incorrect' do before do - mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c', 'failure_count_threshold' => 'not a number' }) + mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c', 'failure_count_threshold' => 'not a number')) end it 'throws an error' do @@ -35,7 +35,7 @@ describe '6_validations' do context 'with invalid storage names' do before do - mock_storages('name with spaces' => { 'path' => 'tmp/tests/paths/a/b/c' }) + mock_storages('name with spaces' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c')) end it 'throws an error' do @@ -67,7 +67,7 @@ describe '6_validations' do describe 'validate_storages_paths' do context 'with correct settings' do before do - mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' }) + mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/d')) end it 'passes through' do @@ -77,7 +77,7 @@ describe '6_validations' do context 'with nested storage paths' do before do - mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c/d' }) + mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c/d')) end it 'throws an error' do @@ -87,7 +87,7 @@ describe '6_validations' do context 'with similar but un-nested storage paths' do before do - mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c2' }) + mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c'), 'bar' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/paths/a/b/c2')) end it 'passes through' do @@ -97,7 +97,7 @@ describe '6_validations' do describe 'inaccessible storage' do before do - mock_storages('foo' => { 'path' => 'tmp/tests/a/path/that/does/not/exist' }) + mock_storages('foo' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/a/path/that/does/not/exist')) end it 'passes through with a warning' do diff --git a/spec/initializers/fog_google_https_private_urls_spec.rb b/spec/initializers/fog_google_https_private_urls_spec.rb new file mode 100644 index 00000000000..de3c157ab7b --- /dev/null +++ b/spec/initializers/fog_google_https_private_urls_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe 'Fog::Storage::GoogleXML::File' do + let(:storage) do + Fog.mock! + Fog::Storage.new({ + google_storage_access_key_id: "asdf", + google_storage_secret_access_key: "asdf", + provider: "Google" + }) + end + + let(:file) do + directory = storage.directories.create(key: 'data') + directory.files.create( + body: 'Hello World!', + key: 'hello_world.txt' + ) + end + + it 'delegates to #get_https_url' do + expect(file.url(Time.now)).to start_with("https://") + end +end diff --git a/spec/initializers/settings_spec.rb b/spec/initializers/settings_spec.rb index 838ca9fabef..57f5adbbc40 100644 --- a/spec/initializers/settings_spec.rb +++ b/spec/initializers/settings_spec.rb @@ -1,5 +1,5 @@ require 'spec_helper' -require_relative '../../config/initializers/1_settings' +require_relative '../../config/initializers/1_settings' unless defined?(Settings) describe Settings do describe '#ldap' do diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js index b8155144e2a..efbe09a10a2 100644 --- a/spec/javascripts/behaviors/copy_as_gfm_spec.js +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -1,4 +1,4 @@ -import { CopyAsGFM } from '~/behaviors/copy_as_gfm'; +import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; describe('CopyAsGFM', () => { describe('CopyAsGFM.pasteGFM', () => { diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js index 1ea8d86cb7e..94a0c999d66 100644 --- a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js @@ -20,7 +20,7 @@ describe('NativeFormVariableList', () => { it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { const $row = $wrapper.find('.js-row'); expect($row.find('.js-ci-variable-input-key').attr('name')).toBe('schedule[variables_attributes][][key]'); - expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][value]'); + expect($row.find('.js-ci-variable-input-value').attr('name')).toBe('schedule[variables_attributes][][secret_value]'); $wrapper.closest('form').trigger('trigger-submit'); diff --git a/spec/javascripts/fixtures/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml index a20390c08ee..43d57c2c4dc 100644 --- a/spec/javascripts/fixtures/gl_dropdown.html.haml +++ b/spec/javascripts/fixtures/gl_dropdown.html.haml @@ -1,7 +1,8 @@ %div .dropdown.inline %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} - Projects + .dropdown-toggle-text + Projects %i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle .dropdown-menu.dropdown-select.dropdown-menu-selectable .dropdown-title diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js index 0e4a7017406..5393502196e 100644 --- a/spec/javascripts/gl_dropdown_spec.js +++ b/spec/javascripts/gl_dropdown_spec.js @@ -256,4 +256,29 @@ describe('glDropdown', function describeDropdown() { }); }); }); + + it('should keep selected item after selecting a second time', () => { + const options = { + isSelectable(item, $el) { + return !$el.hasClass('is-active'); + }, + toggleLabel(item) { + return item && item.id; + }, + }; + initDropDown.call(this, false, false, options); + const $item = $(`${ITEM_SELECTOR}:first() a`, this.$dropdownMenuElement); + + // select item the first time + this.dropdownButtonElement.click(); + $item.click(); + expect($item).toHaveClass('is-active'); + // select item the second time + this.dropdownButtonElement.click(); + $item.click(); + expect($item).toHaveClass('is-active'); + + expect($('.dropdown-toggle-text')).toHaveText(this.projectsData[0].id.toString()); + }); }); + diff --git a/spec/javascripts/helpers/vue_component_helper.js b/spec/javascripts/helpers/vue_component_helper.js new file mode 100644 index 00000000000..257c9f5526a --- /dev/null +++ b/spec/javascripts/helpers/vue_component_helper.js @@ -0,0 +1,3 @@ +export default function removeBreakLine (data) { + return data.replace(/\r?\n|\r/g, ' '); +} diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js new file mode 100644 index 00000000000..987aea7befc --- /dev/null +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import changedFileIcon from '~/ide/components/changed_file_icon.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE changed file icon', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(changedFileIcon); + + vm = createComponent(component, { + file: { + tempFile: false, + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('changedIcon', () => { + it('equals file-modified when not a temp file', () => { + expect(vm.changedIcon).toBe('file-modified'); + }); + + it('equals file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIcon).toBe('file-addition'); + }); + }); + + describe('changedIconClass', () => { + it('includes multi-file-modified when not a temp file', () => { + expect(vm.changedIconClass).toContain('multi-file-modified'); + }); + + it('includes multi-file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIconClass).toContain('multi-file-addition'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js new file mode 100644 index 00000000000..144e78d14b5 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import commitActions from '~/ide/components/commit_sidebar/actions.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar actions', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(commitActions); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.currentBranchId = 'master'; + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 groups', () => { + expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3); + }); + + it('renders current branch text', () => { + expect(vm.$el.textContent).toContain('Commit to master branch'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js new file mode 100644 index 00000000000..5b402886b55 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list collapsed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(listCollapsed); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.changedFiles.push(file('file1'), file('file2')); + vm.$store.state.changedFiles[0].tempFile = true; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders added & modified files count', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js new file mode 100644 index 00000000000..15b66952d99 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import listItem from '~/ide/components/commit_sidebar/list_item.vue'; +import router from '~/ide/ide_router'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list item', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(listItem); + + f = file('test-file'); + + vm = mountComponent(Component, { + file: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file path', () => { + expect( + vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim(), + ).toBe(f.path); + }); + + it('calls discardFileChanges when clicking discard button', () => { + spyOn(vm, 'discardFileChanges'); + + vm.$el.querySelector('.multi-file-discard-btn').click(); + + expect(vm.discardFileChanges).toHaveBeenCalled(); + }); + + it('opens a closed file in the editor when clicking the file path', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.openFileInEditor).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + }); + + it('calls updateViewer with diff when clicking file', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + }); + + describe('computed', () => { + describe('iconName', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconName).toBe('file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconName).toBe('file-addition'); + }); + }); + + describe('iconClass', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconClass).toContain('multi-file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconClass).toContain('multi-file-addition'); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js new file mode 100644 index 00000000000..a62c0a28340 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(commitSidebarList); + + vm = createComponentWithStore(Component, store, { + title: 'Staged', + fileList: [], + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with a list of files', () => { + beforeEach(done => { + const f = file('file name'); + f.changed = true; + vm.fileList.push(f); + + Vue.nextTick(done); + }); + + it('renders list', () => { + expect(vm.$el.querySelectorAll('li').length).toBe(1); + }); + }); + + describe('collapsed', () => { + beforeEach(done => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('hides list', () => { + expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); + expect(vm.$el.querySelector('.help-block')).toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..4e8243439f3 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar radio group', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '2'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('uses label if present', () => { + expect(vm.$el.textContent).toContain('test'); + }); + + it('uses slot if label is not present', done => { + vm.$destroy(); + + vm = new Vue({ + components: { + radioGroup, + }, + store, + template: ` + <radio-group + value="1" + > + Testing slot + </radio-group> + `, + }); + + vm.$mount(); + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('Testing slot'); + + done(); + }); + }); + + it('updates store when changing radio button', done => { + vm.$el.querySelector('input').dispatchEvent(new Event('change')); + + Vue.nextTick(() => { + expect(store.state.commit.commitAction).toBe('1'); + + done(); + }); + }); + + it('renders helpText tooltip', done => { + vm.helpText = 'help text'; + + Vue.nextTick(() => { + const help = vm.$el.querySelector('.help-block'); + + expect(help).not.toBeNull(); + expect(help.getAttribute('data-original-title')).toBe('help text'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach(done => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + showInput: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders input box when commitAction matches value', () => { + expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + }); + + it('hides input when commitAction doesnt match value', done => { + store.state.commit.commitAction = '2'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.form-control')).toBeNull(); + done(); + }); + }); + + it('updates branch name in store on input', done => { + const input = vm.$el.querySelector('.form-control'); + input.value = 'testing-123'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick(() => { + expect(store.state.commit.newBranchName).toBe('testing-123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js new file mode 100644 index 00000000000..e17b051f137 --- /dev/null +++ b/spec/javascripts/ide/components/ide_context_bar_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideContextBar from '~/ide/components/ide_context_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; + +describe('Multi-file editor right context bar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideContextBar); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed', () => { + beforeEach(done => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('adds collapsed class', () => { + expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js new file mode 100644 index 00000000000..9f6cb459f3b --- /dev/null +++ b/spec/javascripts/ide/components/ide_external_links_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import ideExternalLinks from '~/ide/components/ide_external_links.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('ide external links component', () => { + let vm; + let fakeReferrer; + let Component; + + const fakeProjectUrl = '/project/'; + + beforeEach(() => { + Component = Vue.extend(ideExternalLinks); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('goBackUrl', () => { + it('renders the Go Back link with the referrer when present', () => { + fakeReferrer = '/example/README.md'; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeReferrer); + }); + + it('renders the Go Back link with the project url when referrer is not present', () => { + fakeReferrer = ''; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeProjectUrl); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_project_tree_spec.js b/spec/javascripts/ide/components/ide_project_tree_spec.js new file mode 100644 index 00000000000..657682cb39c --- /dev/null +++ b/spec/javascripts/ide/components/ide_project_tree_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import ProjectTree from '~/ide/components/ide_project_tree.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE project tree', () => { + const Component = Vue.extend(ProjectTree); + let vm; + + beforeEach(() => { + vm = createComponent(Component, { + project: { + id: 1, + name: 'test', + web_url: gl.TEST_HOST, + avatar_url: '', + branches: [], + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders identicon when projct has no avatar', () => { + expect(vm.$el.querySelector('.identicon')).not.toBeNull(); + }); + + it('renders avatar image if project has avatar', done => { + vm.project.avatar_url = gl.TEST_HOST; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.identicon')).toBeNull(); + expect(vm.$el.querySelector('img.avatar')).not.toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js new file mode 100644 index 00000000000..e0fbc90ca61 --- /dev/null +++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import ideRepoTree from '~/ide/components/ide_repo_tree.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('IdeRepoTree', () => { + let vm; + let tree; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(ideRepoTree); + + tree = { + tree: [file()], + loading: false, + }; + + vm = createComponent(IdeRepoTree, { + tree, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.loading-file')).toBeNull(); + expect(vm.$el.querySelector('.file')).not.toBeNull(); + }); + + it('renders 3 loading files if tree is loading', done => { + tree.loading = true; + + vm.$nextTick(() => { + expect( + vm.$el.querySelectorAll('.multi-file-loading-container').length, + ).toEqual(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..699dae1ce2f --- /dev/null +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + expect( + vm.$el.querySelector('.multi-file-commit-panel-inner'), + ).not.toBeNull(); + }); + + it('renders loading icon component', done => { + vm.$store.state.loading = true; + + vm.$nextTick(() => { + expect( + vm.$el.querySelector('.multi-file-loading-container'), + ).not.toBeNull(); + expect( + vm.$el.querySelectorAll('.multi-file-loading-container').length, + ).toBe(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js new file mode 100644 index 00000000000..5bd890094cc --- /dev/null +++ b/spec/javascripts/ide/components/ide_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ide from '~/ide/components/ide.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('ide component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ide); + + vm = createComponentWithStore(Component, store, { + emptyStateSvgPath: 'svg', + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('does not render panel right when no files open', () => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + }); + + it('renders panel right when files are open', done => { + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [file()], + }; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..e08abe7d849 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import newDropdown from '~/ide/components/new_dropdown/index.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.path = ''; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders new file, upload and new directory links', () => { + expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe( + 'Upload file', + ); + expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe( + 'New directory', + ); + }); + + describe('createNewItem', () => { + it('sets modalType to blob when new file is clicked', () => { + vm.$el.querySelectorAll('a')[0].click(); + + expect(vm.modalType).toBe('blob'); + }); + + it('sets modalType to tree when new directory is clicked', () => { + vm.$el.querySelectorAll('a')[2].click(); + + expect(vm.modalType).toBe('tree'); + }); + + it('opens modal when link is clicked', done => { + vm.$el.querySelectorAll('a')[0].click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + + done(); + }); + }); + }); + + describe('hideModal', () => { + beforeAll(done => { + vm.openModal = true; + Vue.nextTick(done); + }); + + it('closes modal after toggling', done => { + vm.hideModal(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.modal')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..a6e1e5a0d35 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import modal from '~/ide/components/new_dropdown/modal.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + ['tree', 'blob'].forEach(type => { + describe(type, () => { + beforeEach(() => { + vm = createComponent(Component, { + type, + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + }); + + it(`sets modal title as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe( + `Create new ${title}`, + ); + }); + + it(`sets button label as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe( + `Create ${title}`, + ); + }); + + it(`sets form label as ${type}`, () => { + const title = type === 'tree' ? 'Directory' : 'File'; + + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe( + `${title} name`, + ); + }); + + describe('createEntryInStore', () => { + it('$emits create', () => { + spyOn(vm, '$emit'); + + vm.createEntryInStore(); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + branchId: 'master', + name: 'testing', + type, + }); + }); + }); + }); + }); + + it('focuses field on mount', () => { + document.body.innerHTML += '<div class="js-test"></div>'; + + vm = createComponent( + Component, + { + type: 'tree', + branchId: 'master', + path: '', + }, + '.js-test', + ); + + expect(document.activeElement).toBe(vm.$refs.fieldName); + + vm.$el.remove(); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..2bc5d701601 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,87 @@ +import Vue from 'vue'; +import upload from '~/ide/components/new_dropdown/upload.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + + spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('readFile', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText'); + spyOn(FileReader.prototype, 'readAsDataURL'); + }); + + it('calls readAsText for text files', () => { + const file = { + type: 'text/html', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file); + }); + + it('calls readAsDataURL for non-text files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const target = { + result: 'content', + }; + const binaryTarget = { + result: 'base64,base64content', + }; + const file = { + name: 'file', + }; + + it('creates new file', () => { + vm.createFile(target, file, true); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: target.result, + base64: false, + }); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, file, false); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js new file mode 100644 index 00000000000..113ade269e9 --- /dev/null +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -0,0 +1,173 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import repoCommitSection from '~/ide/components/repo_commit_section.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoCommitSection', () => { + let vm; + + function createComponent() { + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'commitsvg', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.currentBranchId = 'master'; + vm.$store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + + vm.$store.state.rightPanelCollapsed = false; + vm.$store.state.currentBranch = 'master'; + vm.$store.state.changedFiles = [file('file1'), file('file2')]; + vm.$store.state.changedFiles.forEach(f => + Object.assign(f, { + changed: true, + content: 'testing', + }), + ); + + return vm.$mount(); + } + + beforeEach(done => { + vm = createComponent(); + + spyOn(service, 'getTreeData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => + Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), + }), + ); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('empty Stage', () => { + it('renders no changes text', () => { + resetStore(vm.$store); + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'nochangessvg', + committedStateSvgPath: 'svg', + }).$mount(); + + expect( + vm.$el.querySelector('.js-empty-state').textContent.trim(), + ).toContain('No changes'); + expect( + vm.$el.querySelector('.js-empty-state img').getAttribute('src'), + ).toBe('nochangessvg'); + }); + }); + + it('renders a commit section', () => { + const changedFileElements = [ + ...vm.$el.querySelectorAll('.multi-file-commit-list li'), + ]; + const submitCommit = vm.$el.querySelector('form .btn'); + + expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); + expect(changedFileElements.length).toEqual(2); + + changedFileElements.forEach((changedFile, i) => { + expect(changedFile.textContent.trim()).toContain( + vm.$store.state.changedFiles[i].path, + ); + }); + + expect(submitCommit.disabled).toBeTruthy(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); + }); + + it('updates commitMessage in store on input', done => { + const textarea = vm.$el.querySelector('textarea'); + + textarea.value = 'testing commit message'; + + textarea.dispatchEvent(new Event('input')); + + getSetTimeoutPromise() + .then(() => { + expect(vm.$store.state.commit.commitMessage).toBe( + 'testing commit message', + ); + }) + .then(done) + .catch(done.fail); + }); + + describe('discard draft button', () => { + it('hidden when commitMessage is empty', () => { + expect( + vm.$el.querySelector('.multi-file-commit-form .btn-default'), + ).toBeNull(); + }); + + it('resets commitMessage when clicking discard button', done => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-default').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$store.state.commit.commitMessage).not.toBe( + 'testing commit message', + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when submitting', () => { + beforeEach(() => { + spyOn(vm, 'commitChanges'); + }); + + it('calls commitChanges', done => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-success').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.commitChanges).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js new file mode 100644 index 00000000000..ae657e8c881 --- /dev/null +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoEditor from '~/ide/components/repo_editor.vue'; +import monacoLoader from '~/ide/monaco_loader'; +import Editor from '~/ide/lib/editor'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoEditor', () => { + let vm; + + beforeEach(done => { + const f = file(); + const RepoEditor = Vue.extend(repoEditor); + + vm = createComponentWithStore(RepoEditor, store, { + file: f, + }); + + f.active = true; + f.tempFile = true; + f.html = 'testing'; + vm.$store.state.openFiles.push(f); + vm.$store.state.entries[f.path] = f; + vm.monaco = true; + + vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + + Editor.editorInstance.modelManager.dispose(); + }); + + it('renders an ide container', done => { + Vue.nextTick(() => { + expect(vm.shouldHideEditor).toBeFalsy(); + + done(); + }); + }); + + describe('when open file is binary and not raw', () => { + beforeEach(done => { + vm.file.binary = true; + + vm.$nextTick(done); + }); + + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + + it('shows activeFile html', () => { + expect(vm.$el.textContent).toContain('testing'); + }); + }); + + describe('createEditorInstance', () => { + it('calls createInstance when viewer is editor', done => { + spyOn(vm.editor, 'createInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is diff', done => { + vm.$store.state.viewer = 'diff'; + + spyOn(vm.editor, 'createDiffInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('setupEditor', () => { + it('creates new model', () => { + spyOn(vm.editor, 'createModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file); + expect(vm.model).not.toBeNull(); + }); + + it('attaches model to editor', () => { + spyOn(vm.editor, 'attachModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); + }); + + it('adds callback methods', () => { + spyOn(vm.editor, 'onPositionChange').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.onPositionChange).toHaveBeenCalled(); + expect(vm.model.events.size).toBe(1); + }); + + it('updates state when model content changed', done => { + vm.model.setValue('testing 123'); + + setTimeout(() => { + expect(vm.file.content).toBe('testing 123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js new file mode 100644 index 00000000000..c86bdb132b4 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import repoFileButtons from '~/ide/components/repo_file_buttons.vue'; +import createVueComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFileButtons', () => { + const activeFile = file(); + let vm; + + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + + return createVueComponent(RepoFileButtons, { + file: activeFile, + }); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders Raw, Blame, History, Permalink and Preview toggle', done => { + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.textContent.trim()).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.textContent.trim()).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.textContent.trim()).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual( + 'Permalink', + ); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js new file mode 100644 index 00000000000..ff391cb4351 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoFile from '~/ide/components/repo_file.vue'; +import router from '~/ide/ide_router'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFile', () => { + let vm; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + vm = createComponentWithStore(RepoFile, store, propsData); + + vm.$mount(); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders link, icon and name', () => { + createComponent({ + file: file('t4'), + level: 0, + }); + + const name = vm.$el.querySelector('.ide-file-name'); + + expect(name.href).toMatch(''); + expect(name.textContent.trim()).toEqual(vm.file.name); + }); + + it('fires clickFile when the link is clicked', done => { + spyOn(router, 'push'); + createComponent({ + file: file('t3'), + level: 0, + }); + + vm.$el.querySelector('.file-name').click(); + + setTimeout(() => { + expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`); + + done(); + }); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + createComponent({ + file: f, + level: 0, + }); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset + .originalTitle, + ).toContain('Locked by testuser'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js new file mode 100644 index 00000000000..8f9644216bc --- /dev/null +++ b/spec/javascripts/ide/components/repo_loading_file_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoLoadingFile from '~/ide/components/repo_loading_file.vue'; +import { resetStore } from '../helpers'; + +describe('RepoLoadingFile', () => { + let vm; + + function createComponent() { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + store, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy(); + }); + } + + function assertColumns(columns) { + columns.forEach(column => { + const container = column.querySelector('.animation-container'); + const lines = [...container.querySelectorAll(':scope > div')]; + + expect(container).toBeTruthy(); + expect(lines.length).toEqual(6); + assertLines(lines); + }); + } + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 columns of animated LoC', () => { + vm = createComponent(); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', done => { + vm = createComponent(); + vm.$store.state.leftPanelCollapsed = true; + vm.$store.state.openFiles.push('test'); + + vm.$nextTick(() => { + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js new file mode 100644 index 00000000000..ddb5204e3a7 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -0,0 +1,165 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoTab from '~/ide/components/repo_tab.vue'; +import router from '~/ide/ide_router'; +import { file, resetStore } from '../helpers'; + +describe('RepoTab', () => { + let vm; + + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + store, + propsData, + }).$mount(); + } + + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a close link and a name link', () => { + vm = createComponent({ + tab: file(), + }); + vm.$store.state.openFiles.push(vm.tab); + const close = vm.$el.querySelector('.multi-file-tab-close'); + const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); + + expect(close.innerHTML).toContain('#close'); + expect(name.textContent.trim()).toEqual(vm.tab.name); + }); + + it('fires clickFile when the link is clicked', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'clickFile'); + + vm.$el.click(); + + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'closeFile'); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); + }); + + it('changes icon on hover', done => { + const tab = file(); + tab.changed = true; + vm = createComponent({ + tab, + }); + + vm.$el.dispatchEvent(new Event('mouseover')); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).toBeNull(); + + vm.$el.dispatchEvent(new Event('mouseout')); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + vm = createComponent({ + tab: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle, + ).toContain('Locked by testuser'); + }); + }); + + describe('methods', () => { + describe('closeTab', () => { + it('closes tab if file has changed', done => { + const tab = file(); + tab.changed = true; + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.changedFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + expect(vm.$store.state.changedFiles.length).toBe(1); + + done(); + }); + }); + + it('closes tab when clicking close btn', done => { + const tab = file('lose'); + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js new file mode 100644 index 00000000000..ceb0416aff8 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; +import repoTabs from '~/ide/components/repo_tabs.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoTabs', () => { + const openedFiles = [file('open1'), file('open2')]; + const RepoTabs = Vue.extend(repoTabs); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a list of tabs', done => { + vm = createComponent(RepoTabs, { + files: openedFiles, + viewer: 'editor', + hasChanges: false, + }); + openedFiles[0].active = true; + + vm.$nextTick(() => { + const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; + + expect(tabs.length).toEqual(2); + expect(tabs[0].classList.contains('active')).toEqual(true); + expect(tabs[1].classList.contains('active')).toEqual(false); + + done(); + }); + }); + + describe('updated', () => { + it('sets showShadow as true when scroll width is larger than width', done => { + const el = document.createElement('div'); + el.innerHTML = '<div id="test-app"></div>'; + document.body.appendChild(el); + + const style = document.createElement('style'); + style.innerText = ` + .multi-file-tabs { + width: 100px; + } + + .multi-file-tabs .list-unstyled { + display: flex; + overflow-x: auto; + } + `; + document.head.appendChild(style); + + vm = createComponent( + RepoTabs, + { + files: [], + viewer: 'editor', + hasChanges: false, + }, + '#test-app', + ); + + vm + .$nextTick() + .then(() => { + expect(vm.showShadow).toEqual(false); + + vm.files = openedFiles; + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.showShadow).toEqual(true); + + style.remove(); + el.remove(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js new file mode 100644 index 00000000000..98db6defc7a --- /dev/null +++ b/spec/javascripts/ide/helpers.js @@ -0,0 +1,22 @@ +import { decorateData } from '~/ide/stores/utils'; +import state from '~/ide/stores/state'; +import commitState from '~/ide/stores/modules/commit/state'; + +export const resetStore = store => { + const newState = { + ...state(), + commit: commitState(), + }; + store.replaceState(newState); +}; + +export const file = (name = 'name', id = name, type = '') => + decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + lastCommit: {}, + }); diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js new file mode 100644 index 00000000000..af12ca15369 --- /dev/null +++ b/spec/javascripts/ide/lib/common/disposable_spec.js @@ -0,0 +1,44 @@ +import Disposable from '~/ide/lib/common/disposable'; + +describe('Multi-file editor library disposable class', () => { + let instance; + let disposableClass; + + beforeEach(() => { + instance = new Disposable(); + + disposableClass = { + dispose: jasmine.createSpy('dispose'), + }; + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('add', () => { + it('adds disposable classes', () => { + instance.add(disposableClass); + + expect(instance.disposers.size).toBe(1); + }); + }); + + describe('dispose', () => { + beforeEach(() => { + instance.add(disposableClass); + }); + + it('calls dispose on all cached disposers', () => { + instance.dispose(); + + expect(disposableClass.dispose).toHaveBeenCalled(); + }); + + it('clears cached disposers', () => { + instance.dispose(); + + expect(instance.disposers.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..4381f6fcfd0 --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -0,0 +1,129 @@ +/* global monaco */ +import eventHub from '~/ide/eventhub'; +import monacoLoader from '~/ide/monaco_loader'; +import ModelManager from '~/ide/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach(done => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = new ModelManager(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + instance.addModel(file('path-name')); + + expect(instance.models.keys().next().value).toBe('path-name'); + }); + + it('adds model into disposable', () => { + spyOn(instance.disposable, 'add').and.callThrough(); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + spyOn(instance.models, 'get').and.callThrough(); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + + it('adds eventHub listener', () => { + const f = file(); + spyOn(eventHub, '$on').and.callThrough(); + + instance.addModel(f); + + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.path}`, + jasmine.anything(), + ); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + instance.addModel(file('path-name')); + + expect(instance.hasCachedModel('path-name')).toBeTruthy(); + }); + }); + + describe('getModel', () => { + it('returns cached model', () => { + instance.addModel(file('path-name')); + + expect(instance.getModel('path-name')).not.toBeNull(); + }); + }); + + describe('removeCachedModel', () => { + let f; + + beforeEach(() => { + f = file(); + + instance.addModel(f); + }); + + it('clears cached model', () => { + instance.removeCachedModel(f); + + expect(instance.models.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + instance.removeCachedModel(f); + + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.path}`, + jasmine.anything(), + ); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js new file mode 100644 index 00000000000..adc6a93c06b --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -0,0 +1,113 @@ +/* global monaco */ +import eventHub from '~/ide/eventhub'; +import monacoLoader from '~/ide/monaco_loader'; +import Model from '~/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach(done => { + spyOn(eventHub, '$on').and.callThrough(); + + monacoLoader(['vs/editor/editor.main'], () => { + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + }); + + it('adds eventHub listener', () => { + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.path}`, + jasmine.anything(), + ); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe('path'); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('setValue', () => { + it('updates models value', () => { + model.setValue('testing 123'); + + expect(model.getModel().getValue()).toBe('testing 123'); + }); + }); + + describe('onChange', () => { + it('caches event by path', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + expect(model.events.keys().next().value).toBe('path'); + }); + + it('calls callback on change', done => { + const spy = jasmine.createSpy(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setTimeout(() => { + expect(spy).toHaveBeenCalledWith(model, jasmine.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(model.disposable, 'dispose').and.callThrough(); + + model.dispose(); + + expect(model.disposable.dispose).toHaveBeenCalled(); + }); + + it('clears events', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + + model.dispose(); + + expect(model.events.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + model.dispose(); + + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.path}`, + jasmine.anything(), + ); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..092170d086a --- /dev/null +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -0,0 +1,139 @@ +/* global monaco */ +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import Model from '~/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach(done => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + editorInstance.dispose(); + controller.dispose(); + }); + + describe('getAllDecorationsForModel', () => { + it('returns empty array when no decorations exist for model', () => { + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations).toEqual([]); + }); + + it('returns decorations by model URL', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations[0]).toEqual({ decoration: 'decorationValue' }); + }); + }); + + describe('addDecorations', () => { + it('caches decorations in a new map', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + expect(controller.decorations.size).toBe(1); + }); + + it('does not create new cache model', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue2' }, + ]); + + expect(controller.decorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + expect(controller.decorations.size).toBe(1); + expect(controller.decorations.keys().next().value).toBe('path'); + }); + + it('calls decorate method', () => { + spyOn(controller, 'decorate'); + + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + spyOn(controller.editor.instance, 'deltaDecorations'); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith( + [], + [], + ); + }); + + it('caches decorations', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('path'); + }); + }); + + describe('dispose', () => { + it('clears cached decorations', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + controller.dispose(); + + expect(controller.decorations.size).toBe(0); + }); + + it('clears cached editorDecorations', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + controller.dispose(); + + expect(controller.editorDecorations.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js new file mode 100644 index 00000000000..c8f3e9f4830 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -0,0 +1,196 @@ +/* global monaco */ +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import ModelManager from '~/ide/lib/common/model_manager'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import DirtyDiffController, { + getDiffChangeType, + getDecorator, +} from '~/ide/lib/diff/controller'; +import { computeDiff } from '~/ide/lib/diff/diff'; +import { file } from '../../helpers'; + +describe('Multi-file editor library dirty diff controller', () => { + let editorInstance; + let controller; + let modelManager; + let decorationsController; + let model; + + beforeEach(done => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(monaco); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file('path')); + + controller = new DirtyDiffController(modelManager, decorationsController); + + done(); + }); + }); + + afterEach(() => { + controller.dispose(); + model.dispose(); + decorationsController.dispose(); + editorInstance.dispose(); + }); + + describe('getDiffChangeType', () => { + ['added', 'removed', 'modified'].forEach(type => { + it(`returns ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDiffChangeType(change)).toBe(type); + }); + }); + }); + + describe('getDecorator', () => { + ['added', 'removed', 'modified'].forEach(type => { + it(`returns with linesDecorationsClassName for ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDecorator(change).options.linesDecorationsClassName).toBe( + `dirty-diff dirty-diff-${type}`, + ); + }); + + it('returns with line numbers', () => { + const change = { + lineNumber: 1, + endLineNumber: 2, + [type]: true, + }; + + const range = getDecorator(change).range; + + expect(range.startLineNumber).toBe(1); + expect(range.endLineNumber).toBe(2); + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe(1); + }); + }); + }); + + describe('attachModel', () => { + it('adds change event callback', () => { + spyOn(model, 'onChange'); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + spyOn(controller, 'throttledComputeDiff'); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + spyOn(controller.dirtyDiffWorker, 'postMessage'); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls decorations controller decorate', () => { + spyOn(controller.decorationsController, 'decorate'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith( + model, + ); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + spyOn(controller.decorationsController, 'addDecorations'); + + controller.decorate({ data: { changes: [], path: 'path' } }); + + expect( + controller.decorationsController.addDecorations, + ).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); + }); + + it('adds decorations into editor', () => { + const spy = spyOn( + controller.decorationsController.editor.instance, + 'deltaDecorations', + ); + + controller.decorate({ + data: { changes: computeDiff('123', '1234'), path: 'path' }, + }); + + expect(spy).toHaveBeenCalledWith( + [], + [ + { + range: new monaco.Range(1, 1, 1, 1), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }, + ], + ); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(controller.disposable, 'dispose').and.callThrough(); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + spyOn( + controller.dirtyDiffWorker, + 'removeEventListener', + ).and.callThrough(); + + controller.dispose(); + + expect( + controller.dirtyDiffWorker.removeEventListener, + ).toHaveBeenCalledWith('message', jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js new file mode 100644 index 00000000000..57f3ac3d365 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/diff_spec.js @@ -0,0 +1,80 @@ +import { computeDiff } from '~/ide/lib/diff/diff'; + +describe('Multi-file editor library diff calculator', () => { + describe('computeDiff', () => { + it('returns empty array if no changes', () => { + const diff = computeDiff('123', '123'); + + expect(diff).toEqual([]); + }); + + describe('modified', () => { + it('', () => { + const diff = computeDiff('123', '1234')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(2); + }); + }); + + describe('added', () => { + it('', () => { + const diff = computeDiff('123', '123\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(3); + }); + }); + + describe('removed', () => { + it('', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeTruthy(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeTruthy(); + expect(diff.lineNumber).toBe(2); + }); + }); + + it('includes line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.lineNumber).toBe(1); + }); + + it('includes end line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.endLineNumber).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js new file mode 100644 index 00000000000..d149a883166 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_options_spec.js @@ -0,0 +1,11 @@ +import editorOptions from '~/ide/lib/editor_options'; + +describe('Multi-file editor library editor options', () => { + it('returns an array', () => { + expect(editorOptions).toEqual(jasmine.any(Array)); + }); + + it('contains readOnly option', () => { + expect(editorOptions[0].readOnly).toBeDefined(); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js new file mode 100644 index 00000000000..2ccd87de1a7 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -0,0 +1,193 @@ +/* global monaco */ +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + let el; + let holder; + + beforeEach(done => { + el = document.createElement('div'); + holder = document.createElement('div'); + el.appendChild(holder); + + document.body.appendChild(el); + + monacoLoader(['vs/editor/editor.main'], () => { + instance = editor.create(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + + el.remove(); + }); + + it('creates instance of editor', () => { + expect(editor.editorInstance).not.toBeNull(); + }); + + it('creates instance returns cached instance', () => { + expect(editor.create(monaco)).toEqual(instance); + }); + + describe('createInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'create').and.callThrough(); + + instance.createInstance(holder); + + expect(instance.monaco.editor.create).toHaveBeenCalled(); + }); + + it('creates dirty diff controller', () => { + instance.createInstance(holder); + + expect(instance.dirtyDiffController).not.toBeNull(); + }); + + it('creates model manager', () => { + instance.createInstance(holder); + + expect(instance.modelManager).not.toBeNull(); + }); + }); + + describe('createDiffInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough(); + + instance.createDiffInstance(holder); + + expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith(holder, { + model: null, + contextmenu: true, + minimap: { + enabled: false, + }, + readOnly: true, + scrollBeyondLastLine: false, + quickSuggestions: false, + occurrencesHighlight: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, + wordWrap: 'bounded', + }); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + spyOn(instance.modelManager, 'addModel'); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); + }); + }); + + describe('attachModel', () => { + let model; + + beforeEach(() => { + instance.createInstance(document.createElement('div')); + + model = instance.createModel(file()); + }); + + it('sets the current model on the instance', () => { + instance.attachModel(model); + + expect(instance.currentModel).toBe(model); + }); + + it('attaches the model to the current instance', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('sets original & modified when diff editor', () => { + spyOn(instance.instance, 'getEditorType').and.returnValue('vs.editor.IDiffEditor'); + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + }); + + it('attaches the model to the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'attachModel'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model); + }); + + it('re-decorates with the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'reDecorate'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + spyOn(instance.instance, 'setModel'); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + + it('resets instance', () => { + instance.createInstance(document.createElement('div')); + + expect(instance.instance).not.toBeNull(); + + instance.dispose(); + + expect(instance.instance).toBeNull(); + }); + + it('does not dispose modelManager', () => { + spyOn(instance.modelManager, 'dispose'); + + instance.dispose(); + + expect(instance.modelManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not dispose decorationsController', () => { + spyOn(instance.decorationsController, 'dispose'); + + instance.dispose(); + + expect(instance.decorationsController.dispose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js new file mode 100644 index 00000000000..7ab315aa8c8 --- /dev/null +++ b/spec/javascripts/ide/monaco_loader_spec.js @@ -0,0 +1,15 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; +import monacoLoader from '~/ide/monaco_loader'; + +describe('MonacoLoader', () => { + it('calls require.config and exports require', () => { + expect(monacoContext.require.getConfig()).toEqual( + jasmine.objectContaining({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + }), + ); + expect(monacoLoader).toBe(monacoContext.require); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js new file mode 100644 index 00000000000..5b7c8365641 --- /dev/null +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -0,0 +1,421 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import eventHub from '~/ide/eventhub'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store file actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('closeFile', () => { + let localFile; + + beforeEach(() => { + localFile = file('testFile'); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + store.state.entries[localFile.path] = localFile; + }); + + it('closes open files', done => { + store + .dispatch('closeFile', localFile.path) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes file even if file has changes', done => { + store.state.changedFiles.push(localFile); + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('closes file & opens next available file', done => { + const f = { + ...file('newOpenFile'), + url: '/newOpenFile', + }; + + store.state.openFiles.push(f); + store.state.entries[f.path] = f; + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let localFile; + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jasmine.createSpy('scrollToTab'); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + localFile = file('setThisActive'); + + store.state.entries[localFile.path] = localFile; + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file active', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns early if file is already active', done => { + localFile.active = true; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets current active file to not active', done => { + const f = file('newActive'); + store.state.entries[f.path] = f; + localFile.active = true; + store.state.openFiles.push(localFile); + + store + .dispatch('setFileActive', f.path) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('resets location.hash for line highlighting', done => { + location.hash = 'test'; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(location.hash).not.toBe('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getFileData', () => { + let localFile; + + beforeEach(() => { + spyOn(service, 'getFileData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'testing getFileData', + }, + json: () => + Promise.resolve({ + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }), + }), + ); + + localFile = file(`newCreate-${Math.random()}`); + localFile.url = 'getFileDataURL'; + store.state.entries[localFile.path] = localFile; + }); + + it('calls the service', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file data', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }) + .catch(done.fail); + }); + + it('sets document title', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(document.title).toBe('testing getFileData'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file as active', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }) + .catch(done.fail); + }); + + it('updates file raw data', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content'); + + done(); + }) + .catch(done.fail); + }); + + it('adds file into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file once into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array if not changed', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: '', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardFileChanges', () => { + let tmpFile; + + beforeEach(() => { + spyOn(eventHub, '$on'); + + tmpFile = file(); + tmpFile.content = 'testing'; + + store.state.changedFiles.push(tmpFile); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('resets file content', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.content).not.toBe('testing'); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes temp file', done => { + tmpFile.tempFile = true; + tmpFile.opened = true; + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('does not re-open a closed temp file', done => { + tmpFile.tempFile = true; + + expect(tmpFile.opened).toBeFalsy(); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js new file mode 100644 index 00000000000..381f038067b --- /dev/null +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + let projectTree; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + branchId: 'master', + }; + + beforeEach(() => { + spyOn(router, 'push'); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('getFiles', () => { + beforeEach(() => { + spyOn(service, 'getFiles').and.returnValue( + Promise.resolve({ + json: () => + Promise.resolve([ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]), + }), + ); + }); + + it('calls service getFiles', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('adds data into tree', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe( + 'fileinsubfolder.js', + ); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('toggleTreeOpen', () => { + let tree; + + beforeEach(() => { + tree = file('testing', '1', 'tree'); + store.state.entries[tree.path] = tree; + }); + + it('toggles the tree open', done => { + store + .dispatch('toggleTreeOpen', tree.path) + .then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getLastCommitData', () => { + beforeEach(() => { + spyOn(service, 'getTreeLastCommit').and.returnValue( + Promise.resolve({ + headers: { + 'more-logs-url': null, + }, + json: () => + Promise.resolve([ + { + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }, + ]), + }), + ); + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + projectTree = store.state.trees['abcproject/mybranch']; + projectTree.tree.push(file('testing', '1', 'tree')); + projectTree.lastCommitPath = 'lastcommitpath'; + }); + + it('calls service with lastCommitPath', done => { + store + .dispatch('getLastCommitData', projectTree) + .then(() => { + expect(service.getTreeLastCommit).toHaveBeenCalledWith( + 'lastcommitpath', + ); + + done(); + }) + .catch(done.fail); + }); + + it('updates trees last commit data', done => { + store + .dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); + + done(); + }) + .catch(done.fail); + }); + + it('does not update entry if not found', done => { + projectTree.tree[0].name = 'a'; + + store + .dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).not.toBe( + 'commit message', + ); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js new file mode 100644 index 00000000000..cec572f4507 --- /dev/null +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -0,0 +1,306 @@ +import * as urlUtils from '~/lib/utils/url_utility'; +import store from '~/ide/stores'; +import router from '~/ide/ide_router'; +import { resetStore, file } from '../helpers'; + +describe('Multi-file store actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', done => { + spyOn(urlUtils, 'visitUrl'); + + store + .dispatch('redirectToUrl', 'test') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', done => { + store + .dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + beforeEach(() => { + const f = file('discardAll'); + f.changed = true; + + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); + + it('discards changes in file', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.openFiles.changed).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all files from changedFiles state', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('closeAllFiles', () => { + beforeEach(() => { + const f = file('closeAll'); + store.state.openFiles.push(f); + store.state.openFiles[0].opened = true; + store.state.entries[f.path] = f; + }); + + it('closes all open files', done => { + store + .dispatch('closeAllFiles') + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('createTempEntry', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'mybranch'; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('tree', () => { + it('creates temp tree', done => { + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'test', + type: 'tree', + }) + .then(() => { + const entry = store.state.entries.test; + + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates new folder inside another tree', done => { + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing/test', + type: 'tree', + }) + .then(() => { + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('does not create new tree if already exists', done => { + const tree = { + type: 'tree', + path: 'testing', + tempFile: false, + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing', + type: 'tree', + }) + .then(() => { + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('blob', () => { + it('creates temp file', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.tempFile).toBeTruthy(); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe( + 1, + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to open files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to changed files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.changedFiles.length).toBe(1); + expect(store.state.changedFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('sets tmp file as active', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('creates flash message if file already exists', done => { + const f = file('test', '1', 'blob'); + store.state.trees['abcproject/mybranch'].tree = [f]; + store.state.entries[f.path] = f; + + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('popHistoryState', () => {}); + + describe('scrollToTab', () => { + it('focuses the current active element', done => { + document.body.innerHTML += + '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>'; + const el = document.querySelector('.repo-tab'); + spyOn(el, 'focus'); + + store + .dispatch('scrollToTab') + .then(() => { + setTimeout(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); + + describe('updateViewer', () => { + it('updates viewer state', done => { + store + .dispatch('updateViewer', 'diff') + .then(() => { + expect(store.state.viewer).toBe('diff'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js new file mode 100644 index 00000000000..a613f3a21cc --- /dev/null +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -0,0 +1,55 @@ +import * as getters from '~/ide/stores/getters'; +import state from '~/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('activeFile', () => { + it('returns the current active file', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + localState.openFiles[1].active = true; + + expect(getters.activeFile(localState).name).toBe('active'); + }); + + it('returns undefined if no active files are found', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + + expect(getters.activeFile(localState)).toBeNull(); + }); + }); + + describe('modifiedFiles', () => { + it('returns a list of modified files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('changed')); + localState.changedFiles[0].changed = true; + + const modifiedFiles = getters.modifiedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('changed'); + }); + }); + + describe('addedFiles', () => { + it('returns a list of added files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('added')); + localState.changedFiles[0].changed = true; + localState.changedFiles[0].tempFile = true; + + const modifiedFiles = getters.addedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('added'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js new file mode 100644 index 00000000000..90ded940227 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -0,0 +1,505 @@ +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import * as urlUtils from '~/lib/utils/url_utility'; +import eventHub from '~/ide/eventhub'; +import * as consts from '~/ide/stores/modules/commit/constants'; +import { resetStore, file } from 'spec/ide/helpers'; + +describe('IDE commit module actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('updateCommitMessage', () => { + it('updates store with new commit message', done => { + store + .dispatch('commit/updateCommitMessage', 'testing') + .then(() => { + expect(store.state.commit.commitMessage).toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardDraft', () => { + it('resets commit message to blank', done => { + store.state.commit.commitMessage = 'testing'; + + store + .dispatch('commit/discardDraft') + .then(() => { + expect(store.state.commit.commitMessage).not.toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCommitAction', () => { + it('updates store with new commit action', done => { + store + .dispatch('commit/updateCommitAction', '1') + .then(() => { + expect(store.state.commit.commitAction).toBe('1'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateBranchName', () => { + it('updates store with new branch name', done => { + store + .dispatch('commit/updateBranchName', 'branch-name') + .then(() => { + expect(store.state.commit.newBranchName).toBe('branch-name'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setLastCommitMessage', () => { + beforeEach(() => { + Object.assign(store.state, { + currentProjectId: 'abcproject', + projects: { + abcproject: { + web_url: 'http://testing', + }, + }, + }); + }); + + it('updates commit message with short_id', done => { + store + .dispatch('commit/setLastCommitMessage', { short_id: '123' }) + .then(() => { + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('updates commit message with stats', done => { + store + .dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('checkCommitStatus', () => { + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + it('calls service', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); + + store + .dispatch('commit/checkCommitStatus') + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith( + 'abcproject', + 'master', + ); + + done(); + }) + .catch(done.fail); + }); + + it('returns true if current ref does not equal returned ID', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); + + store + .dispatch('commit/checkCommitStatus') + .then(val => { + expect(val).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns false if current ref equals returned ID', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '1' }, + }, + }), + ); + + store + .dispatch('commit/checkCommitStatus') + .then(val => { + expect(val).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('updateFilesAfterCommit', () => { + const data = { + id: '123', + message: 'testing commit message', + committed_date: '123', + committer_name: 'root', + }; + const branch = 'master'; + let f; + + beforeEach(() => { + spyOn(eventHub, '$emit'); + + f = file('changedFile'); + Object.assign(f, { + active: true, + changed: true, + content: 'file content', + }); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + }, + }, + }; + store.state.changedFiles.push(f, { + ...file('changedFile2'), + changed: true, + }); + store.state.openFiles = store.state.changedFiles; + + store.state.changedFiles.forEach(changedFile => { + store.state.entries[changedFile.path] = changedFile; + }); + }); + + it('updates stores working reference', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect( + store.state.projects.abcproject.branches.master.workingReference, + ).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('resets all files changed status', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + store.state.openFiles.forEach(entry => { + expect(entry.changed).toBeFalsy(); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all changed files', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('sets files commit data', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.lastCommit.message).toBe(data.message); + }) + .then(done) + .catch(done.fail); + }); + + it('updates raw content for changed file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.raw).toBe(f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('emits changed event for file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith( + `editor.update.model.content.${f.path}`, + f.content, + ); + }) + .then(done) + .catch(done.fail); + }); + + it('pushes route to new branch if commitAction is new branch', done => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/abcproject/blob/master/${f.path}`, + ); + }) + .then(done) + .catch(done.fail); + }); + + it('resets stores commit actions', done => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.commit.commitAction).not.toBe( + consts.COMMIT_TO_NEW_BRANCH, + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + beforeEach(() => { + spyOn(urlUtils, 'visitUrl'); + + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + }, + }, + }; + store.state.changedFiles.push(file('changed')); + store.state.changedFiles[0].active = true; + store.state.openFiles = store.state.changedFiles; + + store.state.openFiles.forEach(f => { + store.state.entries[f.path] = f; + }); + + store.state.commit.commitAction = '2'; + store.state.commit.commitMessage = 'testing 123'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue( + Promise.resolve({ + data: { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, + }, + }), + ); + }); + + it('calls service', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: jasmine.anything(), + commit_message: 'testing 123', + actions: [ + { + action: 'update', + file_path: jasmine.anything(), + content: jasmine.anything(), + encoding: jasmine.anything(), + }, + ], + start_branch: 'master', + }); + + done(); + }) + .catch(done.fail); + }); + + it('pushes router to new route', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/${store.state.currentProjectId}/blob/${ + store.getters['commit/newBranchName'] + }/changed`, + ); + + done(); + }) + .catch(done.fail); + }); + + it('sets last Commit Msg', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds commit data to changed files', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.openFiles[0].lastCommit.message).toBe( + 'test message', + ); + + done(); + }) + .catch(done.fail); + }); + + it('redirects to new merge request page', done => { + spyOn(eventHub, '$on'); + + store.state.commit.commitAction = '3'; + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + `webUrl/merge_requests/new?merge_request[source_branch]=${ + store.getters['commit/newBranchName'] + }&merge_request[target_branch]=master`, + ); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('failed', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue( + Promise.resolve({ + data: { + message: 'failed message', + }, + }), + ); + }); + + it('shows failed message', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe('failed message'); + + done(); + }) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js new file mode 100644 index 00000000000..e396284ec2c --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -0,0 +1,128 @@ +import commitState from '~/ide/stores/modules/commit/state'; +import * as consts from '~/ide/stores/modules/commit/constants'; +import * as getters from '~/ide/stores/modules/commit/getters'; + +describe('IDE commit module getters', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('discardDraftButtonDisabled', () => { + it('returns true when commitMessage is empty', () => { + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + + it('returns false when commitMessage is not empty & loading is false', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = false; + + expect(getters.discardDraftButtonDisabled(state)).toBeFalsy(); + }); + + it('returns true when commitMessage is not empty & loading is true', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = true; + + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + }); + + describe('commitButtonDisabled', () => { + const localGetters = { + discardDraftButtonDisabled: false, + }; + const rootState = { + changedFiles: ['a'], + }; + + it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeFalsy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { + rootState.changedFiles.length = 0; + + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is true', () => { + localGetters.discardDraftButtonDisabled = true; + + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + localGetters.discardDraftButtonDisabled = false; + rootState.changedFiles.length = 0; + + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); + }); + }); + + describe('newBranchName', () => { + it('includes username, currentBranchId, patch & random number', () => { + gon.current_username = 'username'; + + const branch = getters.newBranchName(state, null, { + currentBranchId: 'testing', + }); + + expect(branch).toMatch(/username-testing-patch-\d{5}$/); + }); + }); + + describe('branchName', () => { + const rootState = { + currentBranchId: 'master', + }; + const localGetters = { + newBranchName: 'newBranchName', + }; + + beforeEach(() => { + Object.assign(state, { + newBranchName: 'state-newBranchName', + }); + }); + + it('defualts to currentBranchId', () => { + expect(getters.branchName(state, null, rootState)).toBe('master'); + }); + + ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach(type => { + describe(type, () => { + beforeEach(() => { + Object.assign(state, { + commitAction: consts[type], + }); + }); + + it('uses newBranchName when not empty', () => { + expect(getters.branchName(state, localGetters, rootState)).toBe( + 'state-newBranchName', + ); + }); + + it('uses getters newBranchName when state newBranchName is empty', () => { + Object.assign(state, { + newBranchName: '', + }); + + expect(getters.branchName(state, localGetters, rootState)).toBe( + 'newBranchName', + ); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js new file mode 100644 index 00000000000..5de7a281d34 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js @@ -0,0 +1,42 @@ +import commitState from '~/ide/stores/modules/commit/state'; +import mutations from '~/ide/stores/modules/commit/mutations'; + +describe('IDE commit module mutations', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('UPDATE_COMMIT_MESSAGE', () => { + it('updates commitMessage', () => { + mutations.UPDATE_COMMIT_MESSAGE(state, 'testing'); + + expect(state.commitMessage).toBe('testing'); + }); + }); + + describe('UPDATE_COMMIT_ACTION', () => { + it('updates commitAction', () => { + mutations.UPDATE_COMMIT_ACTION(state, 'testing'); + + expect(state.commitAction).toBe('testing'); + }); + }); + + describe('UPDATE_NEW_BRANCH_NAME', () => { + it('updates newBranchName', () => { + mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing'); + + expect(state.newBranchName).toBe('testing'); + }); + }); + + describe('UPDATE_LOADING', () => { + it('updates submitCommitLoading', () => { + mutations.UPDATE_LOADING(state, true); + + expect(state.submitCommitLoading).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js new file mode 100644 index 00000000000..a7167537ef2 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -0,0 +1,18 @@ +import mutations from '~/ide/stores/mutations/branch'; +import state from '~/ide/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('SET_CURRENT_BRANCH', () => { + it('sets currentBranch', () => { + mutations.SET_CURRENT_BRANCH(localState, 'master'); + + expect(localState.currentBranchId).toBe('master'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js new file mode 100644 index 00000000000..131380248e8 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -0,0 +1,157 @@ +import mutations from '~/ide/stores/mutations/file'; +import state from '~/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store file mutations', () => { + let localState; + let localFile; + + beforeEach(() => { + localState = state(); + localFile = file(); + + localState.entries[localFile.path] = localFile; + }); + + describe('SET_FILE_ACTIVE', () => { + it('sets the file active', () => { + mutations.SET_FILE_ACTIVE(localState, { + path: localFile.path, + active: true, + }); + + expect(localFile.active).toBeTruthy(); + }); + }); + + describe('TOGGLE_FILE_OPEN', () => { + beforeEach(() => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + }); + + it('adds into opened files', () => { + expect(localFile.opened).toBeTruthy(); + expect(localState.openFiles.length).toBe(1); + }); + + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + describe('SET_FILE_DATA', () => { + it('sets extra file data', () => { + mutations.SET_FILE_DATA(localState, { + data: { + blame_path: 'blame', + commits_path: 'commits', + permalink: 'permalink', + raw_path: 'raw', + binary: true, + render_error: 'render_error', + }, + file: localFile, + }); + + expect(localFile.blamePath).toBe('blame'); + expect(localFile.commitsPath).toBe('commits'); + expect(localFile.permalink).toBe('permalink'); + expect(localFile.rawPath).toBe('raw'); + expect(localFile.binary).toBeTruthy(); + expect(localFile.renderError).toBe('render_error'); + }); + }); + + describe('SET_FILE_RAW_DATA', () => { + it('sets raw data', () => { + mutations.SET_FILE_RAW_DATA(localState, { + file: localFile, + raw: 'testing', + }); + + expect(localFile.raw).toBe('testing'); + }); + }); + + describe('UPDATE_FILE_CONTENT', () => { + beforeEach(() => { + localFile.raw = 'test'; + }); + + it('sets content', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'test', + }); + + expect(localFile.content).toBe('test'); + }); + + it('sets changed if content does not match raw', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'testing', + }); + + expect(localFile.content).toBe('testing'); + expect(localFile.changed).toBeTruthy(); + }); + + it('sets changed if file is a temp file', () => { + localFile.tempFile = true; + + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: '', + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); + + describe('DISCARD_FILE_CHANGES', () => { + beforeEach(() => { + localFile.content = 'test'; + localFile.changed = true; + }); + + it('resets content and changed', () => { + mutations.DISCARD_FILE_CHANGES(localState, localFile.path); + + expect(localFile.content).toBe(''); + expect(localFile.changed).toBeFalsy(); + }); + }); + + describe('ADD_FILE_TO_CHANGED', () => { + it('adds file into changed files array', () => { + mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(1); + }); + }); + + describe('REMOVE_FILE_FROM_CHANGED', () => { + it('removes files from changed files array', () => { + localState.changedFiles.push(localFile); + + mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(0); + }); + }); + + describe('TOGGLE_FILE_CHANGED', () => { + it('updates file changed status', () => { + mutations.TOGGLE_FILE_CHANGED(localState, { + file: localFile, + changed: true, + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js new file mode 100644 index 00000000000..e6c085eaff6 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/tree_spec.js @@ -0,0 +1,69 @@ +import mutations from '~/ide/stores/mutations/tree'; +import state from '~/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store tree mutations', () => { + let localState; + let localTree; + + beforeEach(() => { + localState = state(); + localTree = file(); + + localState.entries[localTree.path] = localTree; + }); + + describe('TOGGLE_TREE_OPEN', () => { + it('toggles tree open', () => { + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeTruthy(); + + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeFalsy(); + }); + }); + + describe('SET_DIRECTORY_DATA', () => { + const data = [ + { + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }, + ]; + + it('adds directory data', () => { + localState.trees['project/master'] = { + tree: [], + }; + + mutations.SET_DIRECTORY_DATA(localState, { + data, + treePath: 'project/master', + }); + + const tree = localState.trees['project/master']; + + expect(tree.tree.length).toBe(3); + expect(tree.tree[0].name).toBe('tree'); + expect(tree.tree[1].name).toBe('submodule'); + expect(tree.tree[2].name).toBe('blob'); + }); + }); + + describe('REMOVE_ALL_CHANGES_FILES', () => { + it('removes all files from changedFiles state', () => { + localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES')); + + mutations.REMOVE_ALL_CHANGES_FILES(localState); + + expect(localState.changedFiles.length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js new file mode 100644 index 00000000000..38162a470ad --- /dev/null +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -0,0 +1,79 @@ +import mutations from '~/ide/stores/mutations'; +import state from '~/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store mutations', () => { + let localState; + let entry; + + beforeEach(() => { + localState = state(); + entry = file(); + + localState.entries[entry.path] = entry; + }); + + describe('SET_INITIAL_DATA', () => { + it('sets all initial data', () => { + mutations.SET_INITIAL_DATA(localState, { + test: 'test', + }); + + expect(localState.test).toBe('test'); + }); + }); + + describe('TOGGLE_LOADING', () => { + it('toggles loading of entry', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeFalsy(); + }); + + it('toggles loading of entry and sets specific value', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry, forceValue: true }); + + expect(entry.loading).toBeTruthy(); + }); + }); + + describe('SET_LEFT_PANEL_COLLAPSED', () => { + it('sets left panel collapsed', () => { + mutations.SET_LEFT_PANEL_COLLAPSED(localState, true); + + expect(localState.leftPanelCollapsed).toBeTruthy(); + + mutations.SET_LEFT_PANEL_COLLAPSED(localState, false); + + expect(localState.leftPanelCollapsed).toBeFalsy(); + }); + }); + + describe('SET_RIGHT_PANEL_COLLAPSED', () => { + it('sets right panel collapsed', () => { + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true); + + expect(localState.rightPanelCollapsed).toBeTruthy(); + + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false); + + expect(localState.rightPanelCollapsed).toBeFalsy(); + }); + }); + + describe('UPDATE_VIEWER', () => { + it('sets viewer state', () => { + mutations.UPDATE_VIEWER(localState, 'diff'); + + expect(localState.viewer).toBe('diff'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js new file mode 100644 index 00000000000..f38ac6dd82f --- /dev/null +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -0,0 +1,66 @@ +import * as utils from '~/ide/stores/utils'; + +describe('Multi-file store utils', () => { + describe('setPageTitle', () => { + it('sets the document page title', () => { + utils.setPageTitle('test'); + + expect(document.title).toBe('test'); + }); + }); + + describe('findIndexOfFile', () => { + let localState; + + beforeEach(() => { + localState = [ + { + path: '1', + }, + { + path: '2', + }, + ]; + }); + + it('finds in the index of an entry by path', () => { + const index = utils.findIndexOfFile(localState, { + path: '2', + }); + + expect(index).toBe(1); + }); + }); + + describe('findEntry', () => { + let localState; + + beforeEach(() => { + localState = { + tree: [ + { + type: 'tree', + name: 'test', + }, + { + type: 'blob', + name: 'file', + }, + ], + }; + }); + + it('returns an entry found by name', () => { + const foundEntry = utils.findEntry(localState.tree, 'tree', 'test'); + + expect(foundEntry.type).toBe('tree'); + expect(foundEntry.name).toBe('test'); + }); + + it('returns undefined when no entry found', () => { + const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); + + expect(foundEntry).toBeUndefined(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 584db6c6632..d5a87b5ce20 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -1,8 +1,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import '~/render_math'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import * as urlUtils from '~/lib/utils/url_utility'; import issuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js index a95a7e2a5be..ca0e7c395a0 100644 --- a/spec/javascripts/lib/utils/text_markdown_spec.js +++ b/spec/javascripts/lib/utils/text_markdown_spec.js @@ -1,4 +1,4 @@ -import textUtils from '~/lib/utils/text_markdown'; +import { insertMarkdownText } from '~/lib/utils/text_markdown'; describe('init markdown', () => { let textArea; @@ -21,7 +21,7 @@ describe('init markdown', () => { textArea.selectionStart = 0; textArea.selectionEnd = 0; - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -32,7 +32,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}\n* `); }); @@ -43,7 +43,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -54,7 +54,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js index eb644e698da..dc9dc4d4249 100644 --- a/spec/javascripts/merge_request_notes_spec.js +++ b/spec/javascripts/merge_request_notes_spec.js @@ -3,8 +3,7 @@ import _ from 'underscore'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; -import '~/render_gfm'; -import '~/render_math'; +import '~/behaviors/markdown/render_gfm'; import Notes from '~/notes'; const upArrowKeyCode = 38; diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 29b355307ef..eba6dcf47c5 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -18,6 +18,7 @@ describe('Dashboard', () => { deploymentEndpoint: null, emptyGettingStartedSvgPath: '/path/to/getting-started.svg', emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', }; diff --git a/spec/javascripts/monitoring/dashboard_state_spec.js b/spec/javascripts/monitoring/dashboard_state_spec.js index df3198dd3e2..b4c5f4baa78 100644 --- a/spec/javascripts/monitoring/dashboard_state_spec.js +++ b/spec/javascripts/monitoring/dashboard_state_spec.js @@ -2,13 +2,22 @@ import Vue from 'vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; import { statePaths } from './mock_data'; -const createComponent = (propsData) => { +function createComponent(props) { const Component = Vue.extend(EmptyState); return new Component({ - propsData, + propsData: { + ...props, + settingsPath: statePaths.settingsPath, + clustersPath: statePaths.clustersPath, + documentationPath: statePaths.documentationPath, + emptyGettingStartedSvgPath: '/path/to/getting-started.svg', + emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', + emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + }, }).$mount(); -}; +} function getTextFromNode(component, selector) { return component.$el.querySelector(selector).firstChild.nodeValue.trim(); @@ -19,11 +28,6 @@ describe('EmptyState', () => { it('currentState', () => { const component = createComponent({ selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.currentState).toBe(component.states.gettingStarted); @@ -32,11 +36,6 @@ describe('EmptyState', () => { it('showButtonDescription returns a description with a link for the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.showButtonDescription).toEqual(true); @@ -45,11 +44,6 @@ describe('EmptyState', () => { it('showButtonDescription returns the description without a link for any other state', () => { const component = createComponent({ selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.showButtonDescription).toEqual(false); @@ -59,12 +53,6 @@ describe('EmptyState', () => { it('should show the gettingStarted state', () => { const component = createComponent({ selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - clustersPath: statePaths.clustersPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); @@ -76,11 +64,6 @@ describe('EmptyState', () => { it('should show the loading state', () => { const component = createComponent({ selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); @@ -92,11 +75,6 @@ describe('EmptyState', () => { it('should show the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js index aed30a087a6..ef6d513444a 100644 --- a/spec/javascripts/notes/components/diff_file_header_spec.js +++ b/spec/javascripts/notes/components/diff_file_header_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffFileHeader from '~/notes/components/diff_file_header.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const discussionFixture = 'merge_requests/diff_discussion.json'; diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js index 7f1f4bf0bcd..f4ec7132dbd 100644 --- a/spec/javascripts/notes/components/diff_with_note_spec.js +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const discussionFixture = 'merge_requests/diff_discussion.json'; const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index ac39418c3e6..0e792eee5e9 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -3,7 +3,7 @@ import _ from 'underscore'; import Vue from 'vue'; import notesApp from '~/notes/components/notes_app.vue'; import service from '~/notes/services/notes_service'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import * as mockData from '../mock_data'; const vueMatchers = { diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index ba0a70bed17..ec56ab0e2f0 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -7,7 +7,7 @@ import * as urlUtils from '~/lib/utils/url_utility'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import Notes from '~/notes'; import timeoutPromise from './helpers/set_timeout_promise_helper'; @@ -16,15 +16,15 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; window.gl = window.gl || {}; gl.utils = gl.utils || {}; - const htmlEscape = (comment) => { - const escapedString = comment.replace(/["&'<>]/g, (a) => { + const htmlEscape = comment => { + const escapedString = comment.replace(/["&'<>]/g, a => { const escapedToken = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', - '`': '`' + '`': '`', }[a]; return escapedToken; @@ -39,7 +39,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; preloadFixtures(commentsTemplate); - beforeEach(function () { + beforeEach(function() { loadFixtures(commentsTemplate); gl.utils.disableButtonIfEmptyField = _.noop; window.project_uploads_path = 'http://test.host/uploads'; @@ -51,6 +51,17 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $('body').removeAttr('data-page'); }); + describe('addBinding', () => { + it('calls postComment when comment button is clicked', () => { + spyOn(Notes.prototype, 'postComment'); + this.notes = new Notes('', []); + + $('.js-comment-button').click(); + + expect(Notes.prototype.postComment).toHaveBeenCalled(); + }); + }); + describe('task lists', function() { let mock; @@ -58,7 +69,13 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; spyOn(axios, 'patch').and.callThrough(); mock = new MockAdapter(axios); - mock.onPatch(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`).reply(200, {}); + mock + .onPatch( + `${ + gl.TEST_HOST + }/frontend-fixtures/merge-requests-project/merge_requests/1.json`, + ) + .reply(200, {}); $('.js-comment-button').on('click', function(e) { e.preventDefault(); @@ -73,18 +90,27 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('modifies the Markdown field', function() { const changeEvent = document.createEvent('HTMLEvents'); changeEvent.initEvent('change', true, true); - $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent); + $('input[type=checkbox]') + .attr('checked', true)[1] + .dispatchEvent(changeEvent); - expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item'); + expect($('.js-task-list-field.original-task-list').val()).toBe( + '- [x] Task List Item', + ); }); it('submits an ajax request on tasklist:changed', function(done) { $('.js-task-list-container').trigger('tasklist:changed'); setTimeout(() => { - expect(axios.patch).toHaveBeenCalledWith(`${gl.TEST_HOST}/frontend-fixtures/merge-requests-project/merge_requests/1.json`, { - note: { note: '' }, - }); + expect(axios.patch).toHaveBeenCalledWith( + `${ + gl.TEST_HOST + }/frontend-fixtures/merge-requests-project/merge_requests/1.json`, + { + note: { note: '' }, + }, + ); done(); }); }); @@ -100,10 +126,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; spyOn(this.notes, 'renderNote').and.stub(); $(textarea).data('autosave', { - reset: function() {} + reset: function() {}, }); - $('.js-comment-button').on('click', (e) => { + $('.js-comment-button').on('click', e => { const $form = $(this); e.preventDefault(); this.notes.addNote($form); @@ -149,7 +175,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; <div class="note-text">${sampleComment}</div> </li>`, note: sampleComment, - valid: true + valid: true, }; $form = $('form.js-main-target-form'); $notesContainer = $('ul.main-notes-list'); @@ -163,7 +189,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mock.restore(); }); - it('updates note and resets edit form', (done) => { + it('updates note and resets edit form', done => { spyOn(this.notes, 'revertNoteEditForm'); spyOn(this.notes, 'setupNewNote'); @@ -175,7 +201,9 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; updatedNote.note = 'bar'; this.notes.updateNote(updatedNote, $targetNote); - expect(this.notes.revertNoteEditForm).toHaveBeenCalledWith($targetNote); + expect(this.notes.revertNoteEditForm).toHaveBeenCalledWith( + $targetNote, + ); expect(this.notes.setupNewNote).toHaveBeenCalled(); done(); @@ -231,17 +259,14 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; note: 'heya', html: '<div>heya</div>', }; - $notesList = jasmine.createSpyObj('$notesList', [ - 'find', - 'append', - ]); + $notesList = jasmine.createSpyObj('$notesList', ['find', 'append']); notes = jasmine.createSpyObj('notes', [ 'setupNewNote', 'refresh', 'collapseLongCommitList', 'updateNotesCount', - 'putConflictEditWarningInPlace' + 'putConflictEditWarningInPlace', ]); notes.taskList = jasmine.createSpyObj('tasklist', ['init']); notes.note_ids = []; @@ -258,7 +283,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; Notes.isNewNote.and.returnValue(true); Notes.prototype.renderNote.call(notes, note, null, $notesList); - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + expect(Notes.animateAppendNote).toHaveBeenCalledWith( + note.html, + $notesList, + ); }); }); @@ -273,7 +301,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; Notes.prototype.renderNote.call(notes, note, null, $notesList); - expect(Notes.animateUpdateNote).toHaveBeenCalledWith(note.html, $note); + expect(Notes.animateUpdateNote).toHaveBeenCalledWith( + note.html, + $note, + ); expect(notes.setupNewNote).toHaveBeenCalledWith($newNote); }); @@ -301,7 +332,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $notesList.find.and.returnValue($note); Notes.prototype.renderNote.call(notes, note, null, $notesList); - expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(note, $note); + expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith( + note, + $note, + ); }); }); }); @@ -311,11 +345,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should consider same note text as the same', () => { const result = Notes.isUpdatedNote( { - note: 'initial' + note: 'initial', }, $(`<div> <div class="original-note-content">initial</div> - </div>`) + </div>`), ); expect(result).toEqual(false); @@ -324,11 +358,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should consider same note with trailing newline as the same', () => { const result = Notes.isUpdatedNote( { - note: 'initial\n' + note: 'initial\n', }, $(`<div> <div class="original-note-content">initial\n</div> - </div>`) + </div>`), ); expect(result).toEqual(false); @@ -337,11 +371,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should consider different notes as different', () => { const result = Notes.isUpdatedNote( { - note: 'foo' + note: 'foo', }, $(`<div> <div class="original-note-content">bar</div> - </div>`) + </div>`), ); expect(result).toEqual(true); @@ -397,7 +431,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should call Notes.animateAppendNote', () => { Notes.prototype.renderDiscussionNote.call(notes, note, $form); - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list')); + expect(Notes.animateAppendNote).toHaveBeenCalledWith( + note.discussion_html, + $('.main-notes-list'), + ); }); it('should append to row selected with line_code', () => { @@ -428,7 +465,10 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('should call Notes.animateAppendNote', () => { - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer); + expect(Notes.animateAppendNote).toHaveBeenCalledWith( + note.html, + discussionContainer, + ); }); }); }); @@ -461,9 +501,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; beforeEach(() => { noteHTML = '<div></div>'; - $note = jasmine.createSpyObj('$note', [ - 'replaceWith' - ]); + $note = jasmine.createSpyObj('$note', ['replaceWith']); $updatedNote = Notes.animateUpdateNote(noteHTML, $note); }); @@ -501,7 +539,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; <div class="note-text">${sampleComment}</div> </li>`, note: sampleComment, - valid: true + valid: true, }; let $form; let $notesContainer; @@ -534,10 +572,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mockNotesPost(); $('.js-comment-button').click(); - expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); + expect($notesContainer.find('.note.being-posted').length > 0).toEqual( + true, + ); }); - it('should remove placeholder note when new comment is done posting', (done) => { + it('should remove placeholder note when new comment is done posting', done => { mockNotesPost(); $('.js-comment-button').click(); @@ -549,19 +589,44 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); - it('should show actual note element when new comment is done posting', (done) => { + describe('postComment', () => { + it('disables the submit button', done => { + const $submitButton = $form.find('.js-comment-submit-button'); + expect($submitButton).not.toBeDisabled(); + const dummyEvent = { + preventDefault() {}, + target: $submitButton, + }; + mock.onPost(NOTES_POST_PATH).replyOnce(() => { + expect($submitButton).toBeDisabled(); + return [200, note]; + }); + + this.notes + .postComment(dummyEvent) + .then(() => { + expect($submitButton).not.toBeDisabled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should show actual note element when new comment is done posting', done => { mockNotesPost(); $('.js-comment-button').click(); setTimeout(() => { - expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true); + expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual( + true, + ); done(); }); }); - it('should reset Form when new comment is done posting', (done) => { + it('should reset Form when new comment is done posting', done => { mockNotesPost(); $('.js-comment-button').click(); @@ -573,19 +638,24 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); - it('should show flash error message when new comment failed to be posted', (done) => { + it('should show flash error message when new comment failed to be posted', done => { mockNotesPostError(); $('.js-comment-button').click(); setTimeout(() => { - expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + expect( + $notesContainer + .parent() + .find('.flash-container .flash-text') + .is(':visible'), + ).toEqual(true); done(); }); }); - it('should show flash error message when comment failed to be updated', (done) => { + it('should show flash error message when comment failed to be updated', done => { mockNotesPost(); $('.js-comment-button').click(); @@ -606,7 +676,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; .then(() => { const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect( + $updatedNoteEl + .find('.note-text') + .text() + .trim(), + ).toEqual(sampleComment); // See if comment reverted back to original expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown done(); @@ -620,12 +695,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; const note = { commands_changes: { assignee_id: 1, - emoji_award: '100' + emoji_award: '100', }, errors: { - commands_only: ['Commands applied'] + commands_only: ['Commands applied'], }, - valid: false + valid: false, }; let $form; let $notesContainer; @@ -640,12 +715,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; window.gon.current_user_fullname = 'Administrator'; gl.awardsHandler = { addAwardToEmojiBar: () => {}, - scrollToAwards: () => {} + scrollToAwards: () => {}, }; gl.GfmAutoComplete = { dataSources: { - commands: '/root/test-project/autocomplete_sources/commands' - } + commands: '/root/test-project/autocomplete_sources/commands', + }, }; $form = $('form.js-main-target-form'); $notesContainer = $('ul.main-notes-list'); @@ -656,14 +731,18 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mock.restore(); }); - it('should remove slash command placeholder when comment with slash commands is done posting', (done) => { + it('should remove slash command placeholder when comment with slash commands is done posting', done => { spyOn(gl.awardsHandler, 'addAwardToEmojiBar').and.callThrough(); $('.js-comment-button').click(); - expect($notesContainer.find('.system-note.being-posted').length).toEqual(1); // Placeholder shown + expect( + $notesContainer.find('.system-note.being-posted').length, + ).toEqual(1); // Placeholder shown setTimeout(() => { - expect($notesContainer.find('.system-note.being-posted').length).toEqual(0); // Placeholder removed + expect( + $notesContainer.find('.system-note.being-posted').length, + ).toEqual(0); // Placeholder removed done(); }); }); @@ -678,7 +757,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; <div class="note-text">${sampleComment}</div> </li>`, note: sampleComment, - valid: true + valid: true, }; let $form; let $notesContainer; @@ -700,7 +779,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; mock.restore(); }); - it('should not render a script tag', (done) => { + it('should not render a script tag', done => { $('.js-comment-button').click(); setTimeout(() => { @@ -709,8 +788,15 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $noteEl.find('textarea.js-note-text').html(updatedComment); $noteEl.find('.js-comment-save-button').click(); - const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`).find('.js-task-list-container'); - expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(''); + const $updatedNoteEl = $notesContainer + .find(`#note_${note.id}`) + .find('.js-task-list-container'); + expect( + $updatedNoteEl + .find('.note-text') + .text() + .trim(), + ).toEqual(''); done(); }); @@ -730,7 +816,9 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should return form metadata object from form reference', () => { $form.find('textarea.js-note-text').val(sampleComment); - const { formData, formContent, formAction } = this.notes.getFormData($form); + const { formData, formContent, formAction } = this.notes.getFormData( + $form, + ); expect(formData.indexOf(sampleComment) > -1).toBe(true); expect(formContent).toEqual(sampleComment); @@ -746,7 +834,9 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; const { formContent } = this.notes.getFormData($form); expect(_.escape).toHaveBeenCalledWith(sampleComment); - expect(formContent).toEqual('<script>alert("Boom!");</script>'); + expect(formContent).toEqual( + '<script>alert("Boom!");</script>', + ); }); }); @@ -756,7 +846,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('should return true when comment begins with a quick action', () => { - const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; + const sampleComment = + '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; const hasQuickActions = this.notes.hasQuickActions(sampleComment); expect(hasQuickActions).toBeTruthy(); @@ -780,7 +871,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; describe('stripQuickActions', () => { it('should strip quick actions from the comment which begins with a quick action', () => { this.notes = new Notes(); - const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; + const sampleComment = + '/wip\n/milestone %1.0\n/merge\n/unassign Merging this'; const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe(''); @@ -788,7 +880,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should strip quick actions from the comment but leaves plain comment if it is present', () => { this.notes = new Notes(); - const sampleComment = '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; + const sampleComment = + '/wip\n/milestone %1.0\n/merge\n/unassign\nMerging this'; const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe('Merging this'); @@ -796,7 +889,8 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should NOT strip string that has slashes within', () => { this.notes = new Notes(); - const sampleComment = 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; + const sampleComment = + 'http://127.0.0.1:3000/root/gitlab-shell/issues/1'; const stripedComment = this.notes.stripQuickActions(sampleComment); expect(stripedComment).toBe(sampleComment); @@ -807,7 +901,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; const availableQuickActions = [ { name: 'close', description: 'Close this issue', params: [] }, { name: 'title', description: 'Change title', params: [{}] }, - { name: 'estimate', description: 'Set time estimate', params: [{}] } + { name: 'estimate', description: 'Set time estimate', params: [{}] }, ]; beforeEach(() => { @@ -816,17 +910,29 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; it('should return executing quick action description when note has single quick action', () => { const sampleComment = '/close'; - expect(this.notes.getQuickActionDescription(sampleComment, availableQuickActions)).toBe('Applying command to close this issue'); + expect( + this.notes.getQuickActionDescription( + sampleComment, + availableQuickActions, + ), + ).toBe('Applying command to close this issue'); }); it('should return generic multiple quick action description when note has multiple quick actions', () => { const sampleComment = '/close\n/title [Duplicate] Issue foobar'; - expect(this.notes.getQuickActionDescription(sampleComment, availableQuickActions)).toBe('Applying multiple commands'); + expect( + this.notes.getQuickActionDescription( + sampleComment, + availableQuickActions, + ), + ).toBe('Applying multiple commands'); }); it('should return generic quick action description when available quick actions list is not populated', () => { const sampleComment = '/close\n/title [Duplicate] Issue foobar'; - expect(this.notes.getQuickActionDescription(sampleComment)).toBe('Applying command'); + expect(this.notes.getQuickActionDescription(sampleComment)).toBe( + 'Applying command', + ); }); }); @@ -856,14 +962,35 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; expect($tempNote.attr('id')).toEqual(uniqueId); expect($tempNote.hasClass('being-posted')).toBeTruthy(); expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); - $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { - expect($(this).attr('href')).toEqual(`/${currentUsername}`); - }); - expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar); - expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); - expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); - expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); - expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment); + $tempNote + .find('.timeline-icon > a, .note-header-info > a') + .each(function() { + expect($(this).attr('href')).toEqual(`/${currentUsername}`); + }); + expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual( + currentUserAvatar, + ); + expect( + $tempNote.find('.timeline-content').hasClass('discussion'), + ).toBeFalsy(); + expect( + $tempNoteHeader + .find('.hidden-xs') + .text() + .trim(), + ).toEqual(currentUserFullname); + expect( + $tempNoteHeader + .find('.note-headline-light') + .text() + .trim(), + ).toEqual(`@${currentUsername}`); + expect( + $tempNote + .find('.note-body .note-text p') + .text() + .trim(), + ).toEqual(sampleComment); }); it('should return constructed placeholder element for discussion note based on form contents', () => { @@ -872,11 +999,13 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; uniqueId, isDiscussionNote: true, currentUsername, - currentUserFullname + currentUserFullname, }); expect($tempNote.prop('nodeName')).toEqual('LI'); - expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); + expect( + $tempNote.find('.timeline-content').hasClass('discussion'), + ).toBeTruthy(); }); it('should return a escaped user name', () => { @@ -890,7 +1019,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; currentUserAvatar, }); const $tempNoteHeader = $tempNote.find('.note-header'); - expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual('Foo <script>alert("XSS")</script>'); + expect( + $tempNoteHeader + .find('.hidden-xs') + .text() + .trim(), + ).toEqual('Foo <script>alert("XSS")</script>'); }); }); @@ -913,7 +1047,12 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; expect($tempNote.attr('id')).toEqual(uniqueId); expect($tempNote.hasClass('being-posted')).toBeTruthy(); expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); - expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription); + expect( + $tempNote + .find('.timeline-content i') + .text() + .trim(), + ).toEqual(sampleCommandDescription); }); }); @@ -923,7 +1062,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('shows a flash message', () => { - this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline.get(0)); + this.notes.addFlash( + 'Error message', + FLASH_TYPE_ALERT, + this.notes.parentTimeline.get(0), + ); expect($('.flash-alert').is(':visible')).toBeTruthy(); }); @@ -936,7 +1079,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); it('hides visible flash message', () => { - this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline.get(0)); + this.notes.addFlash( + 'Error message 1', + FLASH_TYPE_ALERT, + this.notes.parentTimeline.get(0), + ); this.notes.clearFlash(); @@ -944,4 +1091,4 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); }); -}).call(window); +}.call(window)); diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js index ba2e07f02f7..080158a8ee0 100644 --- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js +++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; import eventHub from '~/pages/projects/labels/event_hub'; import axios from '~/lib/utils/axios_utils'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Promote label modal', () => { let vm; diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js index bf044fe8fb5..22956929e7b 100644 --- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; import axios from '~/lib/utils/axios_utils'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Promote milestone modal', () => { let vm; diff --git a/spec/javascripts/performance_bar/components/detailed_metric_spec.js b/spec/javascripts/performance_bar/components/detailed_metric_spec.js new file mode 100644 index 00000000000..c4611dc7662 --- /dev/null +++ b/spec/javascripts/performance_bar/components/detailed_metric_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import detailedMetric from '~/performance_bar/components/detailed_metric.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('detailedMetric', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('when the current request has no details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(detailedMetric), { + currentRequest: {}, + metric: 'gitaly', + header: 'Gitaly calls', + details: 'details', + keys: ['feature', 'request'], + }); + }); + + it('does not render the element', () => { + expect(vm.$el.innerHTML).toEqual(undefined); + }); + }); + + describe('when the current request has details', () => { + const requestDetails = [ + { duration: '100', feature: 'find_commit', request: 'abcdef' }, + { duration: '23', feature: 'rebase_in_progress', request: '' }, + ]; + + beforeEach(() => { + vm = mountComponent(Vue.extend(detailedMetric), { + currentRequest: { + details: { + gitaly: { + duration: '123ms', + calls: '456', + details: requestDetails, + }, + }, + }, + metric: 'gitaly', + header: 'Gitaly calls', + details: 'details', + keys: ['feature', 'request'], + }); + }); + + it('diplays details', () => { + expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456'); + }); + + it('adds a modal with a table of the details', () => { + vm.$el + .querySelectorAll('.performance-bar-modal td strong') + .forEach((duration, index) => { + expect(duration.innerText).toContain(requestDetails[index].duration); + }); + + vm.$el + .querySelectorAll('.performance-bar-modal td:nth-child(2)') + .forEach((feature, index) => { + expect(feature.innerText).toContain(requestDetails[index].feature); + }); + + vm.$el + .querySelectorAll('.performance-bar-modal td:nth-child(3)') + .forEach((request, index) => { + expect(request.innerText).toContain(requestDetails[index].request); + }); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); +}); diff --git a/spec/javascripts/performance_bar/components/performance_bar_app_spec.js b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js new file mode 100644 index 00000000000..9ab9ab1c9f4 --- /dev/null +++ b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import performanceBarApp from '~/performance_bar/components/performance_bar_app.vue'; +import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; +import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import MockAdapter from 'axios-mock-adapter'; + +describe('performance bar', () => { + let mock; + let vm; + + beforeEach(() => { + const store = new PerformanceBarStore(); + + mock = new MockAdapter(axios); + + mock.onGet('/-/peek/results').reply( + 200, + { + data: { + gc: { + invokes: 0, + invoke_time: '0.00', + use_size: 0, + total_size: 0, + total_object: 0, + gc_time: '0.00', + }, + host: { hostname: 'web-01' }, + }, + }, + {}, + ); + + vm = mountComponent(Vue.extend(performanceBarApp), { + store, + env: 'development', + requestId: '123', + peekUrl: '/-/peek/results', + profileUrl: '?lineprofiler=true', + }); + }); + + afterEach(() => { + vm.$destroy(); + mock.restore(); + }); + + it('sets the class to match the environment', () => { + expect(vm.$el.getAttribute('class')).toContain('development'); + }); + + describe('loadRequestDetails', () => { + beforeEach(() => { + spyOn(vm.store, 'addRequest').and.callThrough(); + }); + + it('does nothing if the request cannot be tracked', () => { + spyOn(vm.store, 'canTrackRequest').and.callFake(() => false); + + vm.loadRequestDetails('123', 'https://gitlab.com/'); + + expect(vm.store.addRequest).not.toHaveBeenCalled(); + }); + + it('adds the request immediately', () => { + vm.loadRequestDetails('123', 'https://gitlab.com/'); + + expect(vm.store.addRequest).toHaveBeenCalledWith( + '123', + 'https://gitlab.com/', + ); + }); + + it('makes an HTTP request for the request details', () => { + spyOn(PerformanceBarService, 'fetchRequestDetails').and.callThrough(); + + vm.loadRequestDetails('456', 'https://gitlab.com/'); + + expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledWith( + '/-/peek/results', + '456', + ); + }); + }); +}); diff --git a/spec/javascripts/performance_bar/components/request_selector_spec.js b/spec/javascripts/performance_bar/components/request_selector_spec.js new file mode 100644 index 00000000000..6108a29f8c4 --- /dev/null +++ b/spec/javascripts/performance_bar/components/request_selector_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import requestSelector from '~/performance_bar/components/request_selector.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('request selector', () => { + const requests = [ + { id: '123', url: 'https://gitlab.com/' }, + { + id: '456', + url: 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1', + }, + { + id: '789', + url: + 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1.json?serializer=widget', + }, + ]; + + let vm; + + beforeEach(() => { + vm = mountComponent(Vue.extend(requestSelector), { + requests, + currentRequest: requests[1], + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + function optionText(requestId) { + return vm.$el.querySelector(`[value='${requestId}']`).innerText.trim(); + } + + it('displays the last component of the path', () => { + expect(optionText(requests[2].id)).toEqual('1.json?serializer=widget'); + }); + + it('keeps the last two components of the path when the last component is numeric', () => { + expect(optionText(requests[1].id)).toEqual('merge_requests/1'); + }); + + it('ignores trailing slashes', () => { + expect(optionText(requests[0].id)).toEqual('gitlab.com'); + }); +}); diff --git a/spec/javascripts/performance_bar/components/simple_metric_spec.js b/spec/javascripts/performance_bar/components/simple_metric_spec.js new file mode 100644 index 00000000000..98b843e9711 --- /dev/null +++ b/spec/javascripts/performance_bar/components/simple_metric_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import simpleMetric from '~/performance_bar/components/simple_metric.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('simpleMetric', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('when the current request has no details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(simpleMetric), { + currentRequest: {}, + metric: 'gitaly', + }); + }); + + it('does not display details', () => { + expect(vm.$el.innerText).not.toContain('/'); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); + + describe('when the current request has details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(simpleMetric), { + currentRequest: { + details: { gitaly: { duration: '123ms', calls: '456' } }, + }, + metric: 'gitaly', + }); + }); + + it('diplays details', () => { + expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456'); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); +}); diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index faaf710cf6f..b0d714cbefb 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import initCopyAsGFM from '~/behaviors/copy_as_gfm'; +import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/shortcuts_issuable'; initCopyAsGFM(); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js index 31710551399..d9c03296857 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -23,9 +23,7 @@ const metricsMockData = { memory_values: [ { metric: {}, - values: [ - [1493716685, '4.30859375'], - ], + values: [[1493716685, '4.30859375']], }, ], }, @@ -53,7 +51,8 @@ const createComponent = () => { const messages = { loadingMetrics: 'Loading deployment statistics', - hasMetrics: 'Memory usage unchanged from 0MB to 0MB', + hasMetrics: + '<a href="/root/acets-review-apps/environments/15/metrics"> Memory </a> usage is <b> unchanged </b> at 0MB', loadFailed: 'Failed to load deployment statistics', metricsUnavailable: 'Deployment statistics are not available currently', }; @@ -92,26 +91,26 @@ describe('MemoryUsage', () => { }); describe('computed', () => { - describe('memoryChangeType', () => { - it('should return "increased" if memoryFrom value is less than memoryTo value', () => { + describe('memoryChangeMessage', () => { + it('should contain "increased" if memoryFrom value is less than memoryTo value', () => { vm.memoryFrom = 4.28; vm.memoryTo = 9.13; - expect(vm.memoryChangeType).toEqual('increased'); + expect(vm.memoryChangeMessage.indexOf('increased')).not.toEqual('-1'); }); - it('should return "decreased" if memoryFrom value is less than memoryTo value', () => { + it('should contain "decreased" if memoryFrom value is less than memoryTo value', () => { vm.memoryFrom = 9.13; vm.memoryTo = 4.28; - expect(vm.memoryChangeType).toEqual('decreased'); + expect(vm.memoryChangeMessage.indexOf('decreased')).not.toEqual('-1'); }); - it('should return "unchanged" if memoryFrom value equal to memoryTo value', () => { + it('should contain "unchanged" if memoryFrom value equal to memoryTo value', () => { vm.memoryFrom = 1; vm.memoryTo = 1; - expect(vm.memoryChangeType).toEqual('unchanged'); + expect(vm.memoryChangeMessage.indexOf('unchanged')).not.toEqual('-1'); }); }); }); @@ -130,7 +129,13 @@ describe('MemoryUsage', () => { describe('computeGraphData', () => { it('should populate sparkline graph', () => { vm.computeGraphData(metrics, deployment_time); - const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm; + const { + hasMetrics, + memoryMetrics, + deploymentTime, + memoryFrom, + memoryTo, + } = vm; expect(hasMetrics).toBeTruthy(); expect(memoryMetrics.length > 0).toBeTruthy(); @@ -141,20 +146,26 @@ describe('MemoryUsage', () => { }); describe('loadMetrics', () => { - const returnServicePromise = () => new Promise((resolve) => { - resolve({ - data: metricsMockData, + const returnServicePromise = () => + new Promise(resolve => { + resolve({ + data: metricsMockData, + }); }); - }); - it('should load metrics data using MRWidgetService', (done) => { - spyOn(MRWidgetService, 'fetchMetrics').and.returnValue(returnServicePromise(true)); + it('should load metrics data using MRWidgetService', done => { + spyOn(MRWidgetService, 'fetchMetrics').and.returnValue( + returnServicePromise(true), + ); spyOn(vm, 'computeGraphData'); vm.loadMetrics(); setTimeout(() => { expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url); - expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time); + expect(vm.computeGraphData).toHaveBeenCalledWith( + metrics, + deployment_time, + ); done(); }, 333); }); @@ -167,51 +178,67 @@ describe('MemoryUsage', () => { expect(el.querySelector('.js-usage-info')).toBeDefined(); }); - it('should show loading metrics message while metrics are being loaded', (done) => { + it('should show loading metrics message while metrics are being loaded', done => { vm.loadingMetrics = true; vm.hasMetrics = false; vm.loadFailed = false; Vue.nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); - expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); + expect( + el.querySelector('.js-usage-info.usage-info-loading'), + ).toBeDefined(); + expect( + el.querySelector('.js-usage-info .usage-info-load-spinner'), + ).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain( + messages.loadingMetrics, + ); done(); }); }); - it('should show deployment memory usage when metrics are loaded', (done) => { + it('should show deployment memory usage when metrics are loaded', done => { vm.loadingMetrics = false; vm.hasMetrics = true; vm.loadFailed = false; Vue.nextTick(() => { expect(el.querySelector('.memory-graph-container')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); + expect(el.querySelector('.js-usage-info').innerText).toContain( + messages.hasMetrics, + ); done(); }); }); - it('should show failure message when metrics loading failed', (done) => { + it('should show failure message when metrics loading failed', done => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = true; Vue.nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); + expect( + el.querySelector('.js-usage-info.usage-info-failed'), + ).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain( + messages.loadFailed, + ); done(); }); }); - it('should show metrics unavailable message when metrics loading failed', (done) => { + it('should show metrics unavailable message when metrics loading failed', done => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = false; Vue.nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); + expect( + el.querySelector('.js-usage-info.usage-info-unavailable'), + ).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain( + messages.metricsUnavailable, + ); done(); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index 5323523abc0..fcbd8169bc7 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import conflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import removeBreakLine from 'spec/helpers/vue_component_helper'; describe('MRWidgetConflicts', () => { let Component; @@ -78,8 +79,9 @@ describe('MRWidgetConflicts', () => { }); it('should tell you to rebase locally', () => { - expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toContain('Fast-forward merge is not possible.'); - expect(vm.$el.textContent.trim().replace(/\s\s+/g, ' ')).toContain('To merge this request, first rebase locally'); + expect( + removeBreakLine(vm.$el.textContent).trim(), + ).toContain('Fast-forward merge is not possible. To merge this request, first rebase locally.'); }); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js index baacbc03fb1..894dbe3382f 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_pipeline_blocked_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import pipelineBlockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import removeBreakLine from 'spec/helpers/vue_component_helper'; describe('MRWidgetPipelineBlocked', () => { let vm; @@ -18,6 +19,8 @@ describe('MRWidgetPipelineBlocked', () => { }); it('renders information text', () => { - expect(vm.$el.textContent.trim().replace(/[\r\n]+/g, ' ')).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed'); + expect( + removeBreakLine(vm.$el.textContent).trim(), + ).toContain('Pipeline blocked. The pipeline for this merge request requires a manual action to proceed'); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js index 4c67504b642..b02af94d03a 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -1,16 +1,25 @@ import Vue from 'vue'; -import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch'; +import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import removeBreakLine from 'spec/helpers/vue_component_helper'; -describe('MRWidgetSHAMismatch', () => { - describe('template', () => { - const Component = Vue.extend(shaMismatchComponent); - const vm = new Component({ - el: document.createElement('div'), - }); - it('should have correct elements', () => { - expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); - expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging'); - }); +describe('ShaMismatch', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ShaMismatch); + vm = mountComponent(Component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render information message', () => { + expect(vm.$el.querySelector('button').disabled).toEqual(true); + + expect( + removeBreakLine(vm.$el.textContent).trim(), + ).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging'); }); }); diff --git a/spec/javascripts/vue_shared/components/modal_spec.js b/spec/javascripts/vue_shared/components/deprecated_modal_spec.js index d01a94c25e5..59d4e549a91 100644 --- a/spec/javascripts/vue_shared/components/modal_spec.js +++ b/spec/javascripts/vue_shared/components/deprecated_modal_spec.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import Vue from 'vue'; -import modal from '~/vue_shared/components/modal.vue'; +import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; -const modalComponent = Vue.extend(modal); +const modalComponent = Vue.extend(DeprecatedModal); -describe('Modal', () => { +describe('DeprecatedModal', () => { let vm; afterEach(() => { diff --git a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js index 818ef0af3c2..3e708f865c8 100644 --- a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('toolbar', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js index 8daaf018396..6fe95153204 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js @@ -3,9 +3,9 @@ import Vue from 'vue'; import LabelsSelect from '~/labels_select'; import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = (config = mockConfig) => { const Component = Vue.extend(baseComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index ec63ac306d0..f25c70db125 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const componentConfig = Object.assign({}, mockConfig, { fieldName: 'label_id[]', diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index 5cb4bb6fea6..ce559fe0335 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; -import { mockSuggestedColors } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockSuggestedColors } from './mock_data'; const createComponent = (headerTitle) => { const Component = Vue.extend(dropdownCreateLabelComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js index 0f4fa716f8a..debeab25bd6 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; -import { mockConfig } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig } from './mock_data'; const createComponent = ( labelsWebUrl = mockConfig.labelsWebUrl, diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js index 325fa47c957..cdf234bb0c4 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(dropdownHeaderComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js index 703b87498c7..88733922a59 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue'; -import { mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockLabels } from './mock_data'; const createComponent = (name = 'label_id[]', label = mockLabels[0]) => { const Component = Vue.extend(dropdownHiddenInputComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js index 69e11d966c2..57608d957e7 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(dropdownSearchInputComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js index c3580933072..7c3d2711f65 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = (canEdit = true) => { const Component = Vue.extend(dropdownTitleComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 93b42795bea..39040670a87 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; -import { mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 66e0957b431..4397b00acfa 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = ( labels = mockLabels, diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb index 5100f5737c2..84688845fa5 100644 --- a/spec/lib/backup/manager_spec.rb +++ b/spec/lib/backup/manager_spec.rb @@ -278,6 +278,10 @@ describe Backup::Manager do connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) end + after do + Fog.unmock! + end + context 'target path' do it 'uses the tar filename by default' do expect_any_instance_of(Fog::Collection).to receive(:create) diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index a9b5ed1112a..03573c304aa 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -33,7 +33,7 @@ describe Backup::Repository do let(:timestamp) { Time.utc(2017, 3, 22) } let(:temp_dirs) do Gitlab.config.repositories.storages.map do |name, storage| - File.join(storage['path'], '..', 'repositories.old.' + timestamp.to_i.to_s) + File.join(storage.legacy_disk_path, '..', 'repositories.old.' + timestamp.to_i.to_s) end end diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb index b502daea418..a50329473ad 100644 --- a/spec/lib/banzai/filter/autolink_filter_spec.rb +++ b/spec/lib/banzai/filter/autolink_filter_spec.rb @@ -122,14 +122,10 @@ describe Banzai::Filter::AutolinkFilter do end it 'does not include trailing punctuation' do - doc = filter("See #{link}.") - expect(doc.at_css('a').text).to eq link - - doc = filter("See #{link}, ok?") - expect(doc.at_css('a').text).to eq link - - doc = filter("See #{link}...") - expect(doc.at_css('a').text).to eq link + ['.', ', ok?', '...', '?', '!', ': is that ok?'].each do |trailing_punctuation| + doc = filter("See #{link}#{trailing_punctuation}") + expect(doc.at_css('a').text).to eq link + end end it 'includes trailing punctuation when part of a balanced pair' do @@ -171,6 +167,15 @@ describe Banzai::Filter::AutolinkFilter do expect(actual).to eq(expected_complicated_link) end + it 'does not double-encode HTML entities' do + encoded_link = "#{link}?foo=bar&baz=quux" + expected_encoded_link = %Q{<a href="#{encoded_link}">#{encoded_link}</a>} + actual = unescape(filter(encoded_link).to_html) + + expect(actual).to eq(Rinku.auto_link(encoded_link)) + expect(actual).to eq(expected_encoded_link) + end + it 'does not include trailing HTML entities' do doc = filter("See <<<#{link}>>>") diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb index 17347768a49..a5373517ac8 100644 --- a/spec/lib/banzai/filter/issuable_state_filter_spec.rb +++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb @@ -8,6 +8,7 @@ describe Banzai::Filter::IssuableStateFilter do let(:context) { { current_user: user, issuable_state_filter_enabled: true } } let(:closed_issue) { create_issue(:closed) } let(:project) { create(:project, :public) } + let(:group) { create(:group) } let(:other_project) { create(:project, :public) } def create_link(text, data) @@ -77,6 +78,13 @@ describe Banzai::Filter::IssuableStateFilter do expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)} (closed)") end + it 'handles references from group scopes' do + link = create_link(closed_issue.to_reference(other_project), issue: closed_issue.id, reference_type: 'issue') + doc = filter(link, context.merge(project: nil, group: group)) + + expect(doc.css('a').last.text).to eq("#{closed_issue.to_reference(other_project)} (closed)") + end + it 'skips cross project references if the user cannot read cross project' do expect(Ability).to receive(:allowed?).with(user, :read_cross_project) { false } link = create_link(closed_issue.to_reference(other_project), issue: closed_issue.id, reference_type: 'issue') diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 2a0e19ae796..e1782cff81a 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -48,7 +48,7 @@ module Gitlab }, 'images' => { input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', - output: "<img src=\"https://localhost.com/image.png\" alt=\"Alt text\">" + output: "<div>\n<p><span><img src=\"https://localhost.com/image.png\" alt='Alt text\" onerror=\"alert(7)'></span></p>\n</div>" }, 'pre' => { input: '```mypre"><script>alert(3)</script>', diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index f969f9e8e38..18cef8ec996 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -315,13 +315,19 @@ describe Gitlab::Auth do it "tries to autheticate with db before ldap" do expect(Gitlab::Auth::LDAP::Authentication).not_to receive(:login) - gl_auth.find_with_user_password(username, password) + expect(gl_auth.find_with_user_password(username, password)).to eq(user) + end + + it "does not find user by using ldap as fallback to for authentication" do + expect(Gitlab::Auth::LDAP::Authentication).to receive(:login).and_return(nil) + + expect(gl_auth.find_with_user_password('ldap_user', 'password')).to be_nil end - it "uses ldap as fallback to for authentication" do - expect(Gitlab::Auth::LDAP::Authentication).to receive(:login) + it "find new user by using ldap as fallback to for authentication" do + expect(Gitlab::Auth::LDAP::Authentication).to receive(:login).and_return(user) - gl_auth.find_with_user_password('ldap_user', 'password') + expect(gl_auth.find_with_user_password('ldap_user', 'password')).to eq(user) end end diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb index 5cb1f4deb5f..0dc3705825d 100644 --- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb @@ -54,7 +54,7 @@ describe ::Gitlab::BareRepositoryImport::Repository do context 'hashed storage' do let(:gitlab_shell) { Gitlab::Shell.new } let(:repository_storage) { 'default' } - let(:root_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + let(:root_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } let(:hash) { '6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b' } let(:hashed_path) { "@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" } let(:repo_path) { File.join(root_path, "#{hashed_path}.git") } diff --git a/spec/lib/gitlab/checks/project_moved_spec.rb b/spec/lib/gitlab/checks/project_moved_spec.rb index e263d29656c..8e9386b1ba1 100644 --- a/spec/lib/gitlab/checks/project_moved_spec.rb +++ b/spec/lib/gitlab/checks/project_moved_spec.rb @@ -44,44 +44,17 @@ describe Gitlab::Checks::ProjectMoved, :clean_gitlab_redis_shared_state do end describe '#message' do - context 'when the push is rejected' do - it 'returns a redirect message telling the user to try again' do - project_moved = described_class.new(project, user, 'http', 'foo/bar') - message = "Project 'foo/bar' was moved to '#{project.full_path}'." + - "\n\nPlease update your Git remote:" + - "\n\n git remote set-url origin #{project.http_url_to_repo} and try again.\n" + it 'returns a redirect message' do + project_moved = described_class.new(project, user, 'http', 'foo/bar') + message = <<~MSG + Project 'foo/bar' was moved to '#{project.full_path}'. - expect(project_moved.message(rejected: true)).to eq(message) - end - end + Please update your Git remote: - context 'when the push is not rejected' do - it 'returns a redirect message' do - project_moved = described_class.new(project, user, 'http', 'foo/bar') - message = "Project 'foo/bar' was moved to '#{project.full_path}'." + - "\n\nPlease update your Git remote:" + - "\n\n git remote set-url origin #{project.http_url_to_repo}\n" + git remote set-url origin #{project.http_url_to_repo} + MSG - expect(project_moved.message).to eq(message) - end - end - end - - describe '#permanent_redirect?' do - context 'with a permanent RedirectRoute' do - it 'returns true' do - project.route.create_redirect('foo/bar', permanent: true) - project_moved = described_class.new(project, user, 'http', 'foo/bar') - expect(project_moved.permanent_redirect?).to be_truthy - end - end - - context 'without a permanent RedirectRoute' do - it 'returns false' do - project.route.create_redirect('foo/bar') - project_moved = described_class.new(project, user, 'http', 'foo/bar') - expect(project_moved.permanent_redirect?).to be_falsy - end + expect(project_moved.message).to eq(message) end end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb index 1b03227d67b..dc12ba076bc 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb @@ -5,23 +5,23 @@ describe Gitlab::Ci::Pipeline::Chain::Create do set(:user) { create(:user) } let(:pipeline) do - build(:ci_pipeline_with_one_job, project: project, - ref: 'master') + build(:ci_empty_pipeline, project: project, ref: 'master') end let(:command) do Gitlab::Ci::Pipeline::Chain::Command.new( - project: project, - current_user: user, seeds_block: nil) + project: project, current_user: user) end let(:step) { described_class.new(pipeline, command) } - before do - step.perform! - end - context 'when pipeline is ready to be saved' do + before do + pipeline.stages.build(name: 'test', project: project) + + step.perform! + end + it 'saves a pipeline' do expect(pipeline).to be_persisted end @@ -32,6 +32,7 @@ describe Gitlab::Ci::Pipeline::Chain::Create do it 'creates stages' do expect(pipeline.reload.stages).to be_one + expect(pipeline.stages.first).to be_persisted end end @@ -40,6 +41,10 @@ describe Gitlab::Ci::Pipeline::Chain::Create do build(:ci_pipeline, project: project, ref: nil) end + before do + step.perform! + end + it 'breaks the chain' do expect(step.break?).to be true end @@ -49,18 +54,4 @@ describe Gitlab::Ci::Pipeline::Chain::Create do .to include /Failed to persist the pipeline/ end end - - context 'when there is a seed block present' do - let(:seeds) { spy('pipeline seeds') } - - let(:command) do - double('command', project: project, - current_user: user, - seeds_block: seeds) - end - - it 'executes the block' do - expect(seeds).to have_received(:call).with(pipeline) - end - end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb new file mode 100644 index 00000000000..2258ae83f38 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb @@ -0,0 +1,153 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Chain::Populate do + set(:project) { create(:project) } + set(:user) { create(:user) } + + let(:pipeline) do + build(:ci_pipeline_with_one_job, project: project, + ref: 'master') + end + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + seeds_block: nil) + end + + let(:step) { described_class.new(pipeline, command) } + + context 'when pipeline doesn not have seeds block' do + before do + step.perform! + end + + it 'does not persist the pipeline' do + expect(pipeline).not_to be_persisted + end + + it 'does not break the chain' do + expect(step.break?).to be false + end + + it 'populates pipeline with stages' do + expect(pipeline.stages).to be_one + expect(pipeline.stages.first).not_to be_persisted + end + + it 'populates pipeline with builds' do + expect(pipeline.builds).to be_one + expect(pipeline.builds.first).not_to be_persisted + expect(pipeline.stages.first.builds).to be_one + expect(pipeline.stages.first.builds.first).not_to be_persisted + end + end + + context 'when pipeline is empty' do + let(:config) do + { rspec: { + script: 'ls', + only: ['something'] + } } + end + + let(:pipeline) do + build(:ci_pipeline, project: project, config: config) + end + + before do + step.perform! + end + + it 'breaks the chain' do + expect(step.break?).to be true + end + + it 'appends an error about missing stages' do + expect(pipeline.errors.to_a) + .to include 'No stages / jobs for this pipeline.' + end + end + + context 'when pipeline has validation errors' do + let(:pipeline) do + build(:ci_pipeline, project: project, ref: nil) + end + + before do + step.perform! + end + + it 'breaks the chain' do + expect(step.break?).to be true + end + + it 'appends validation error' do + expect(pipeline.errors.to_a) + .to include 'Failed to build the pipeline!' + end + end + + context 'when there is a seed blocks present' do + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + seeds_block: seeds_block) + end + + context 'when seeds block builds some resources' do + let(:seeds_block) do + ->(pipeline) { pipeline.variables.build(key: 'VAR', value: '123') } + end + + it 'populates pipeline with resources described in the seeds block' do + step.perform! + + expect(pipeline).not_to be_persisted + expect(pipeline.variables).not_to be_empty + expect(pipeline.variables.first).not_to be_persisted + expect(pipeline.variables.first.key).to eq 'VAR' + expect(pipeline.variables.first.value).to eq '123' + end + end + + context 'when seeds block tries to persist some resources' do + let(:seeds_block) do + ->(pipeline) { pipeline.variables.create!(key: 'VAR', value: '123') } + end + + it 'raises exception' do + expect { step.perform! }.to raise_error(ActiveRecord::RecordNotSaved) + end + end + end + + context 'when pipeline gets persisted during the process' do + let(:pipeline) { create(:ci_pipeline, project: project) } + + it 'raises error' do + expect { step.perform! }.to raise_error(described_class::PopulateError) + end + end + + context 'when using only/except build policies' do + let(:config) do + { rspec: { script: 'rspec', stage: 'test', only: ['master'] }, + prod: { script: 'cap prod', stage: 'deploy', only: ['tags'] } } + end + + let(:pipeline) do + build(:ci_pipeline, ref: 'master', config: config) + end + + it 'populates pipeline according to used policies' do + step.perform! + + expect(pipeline.stages.size).to eq 1 + expect(pipeline.builds.size).to eq 1 + expect(pipeline.builds.first.name).to eq 'rspec' + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb index 5c12c6e6392..c53294d091c 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/config_spec.rb @@ -76,28 +76,6 @@ describe Gitlab::Ci::Pipeline::Chain::Validate::Config do end end - context 'when pipeline has no stages / jobs' do - let(:config) do - { rspec: { - script: 'ls', - only: ['something'] - } } - end - - let(:pipeline) do - build(:ci_pipeline, project: project, config: config) - end - - it 'appends an error about missing stages' do - expect(pipeline.errors.to_a) - .to include 'No stages / jobs for this pipeline.' - end - - it 'breaks the chain' do - expect(step.break?).to be true - end - end - context 'when pipeline contains configuration validation errors' do let(:config) { { rspec: {} } } diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb new file mode 100644 index 00000000000..116573379e0 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -0,0 +1,242 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Seed::Build do + let(:pipeline) { create(:ci_empty_pipeline) } + + let(:attributes) do + { name: 'rspec', + ref: 'master', + commands: 'rspec' } + end + + subject do + described_class.new(pipeline, attributes) + end + + describe '#attributes' do + it 'returns hash attributes of a build' do + expect(subject.attributes).to be_a Hash + expect(subject.attributes) + .to include(:name, :project, :ref, :commands) + end + end + + describe '#user=' do + let(:user) { build(:user) } + + it 'assignes user to a build' do + subject.user = user + + expect(subject.attributes).to include(user: user) + end + end + + describe '#to_resource' do + it 'returns a valid build resource' do + expect(subject.to_resource).to be_a(::Ci::Build) + expect(subject.to_resource).to be_valid + end + + it 'memoizes a resource object' do + build = subject.to_resource + + expect(build.object_id).to eq subject.to_resource.object_id + end + + it 'can not be persisted without explicit assignment' do + build = subject.to_resource + + pipeline.save! + + expect(build).not_to be_persisted + end + end + + describe 'applying only/except policies' do + context 'when no branch policy is specified' do + let(:attributes) { { name: 'rspec' } } + + it { is_expected.to be_included } + end + + context 'when branch policy does not match' do + context 'when using only' do + let(:attributes) { { name: 'rspec', only: { refs: ['deploy'] } } } + + it { is_expected.not_to be_included } + end + + context 'when using except' do + let(:attributes) { { name: 'rspec', except: { refs: ['deploy'] } } } + + it { is_expected.to be_included } + end + end + + context 'when branch regexp policy does not match' do + context 'when using only' do + let(:attributes) { { name: 'rspec', only: { refs: ['/^deploy$/'] } } } + + it { is_expected.not_to be_included } + end + + context 'when using except' do + let(:attributes) { { name: 'rspec', except: { refs: ['/^deploy$/'] } } } + + it { is_expected.to be_included } + end + end + + context 'when branch policy matches' do + context 'when using only' do + let(:attributes) { { name: 'rspec', only: { refs: %w[deploy master] } } } + + it { is_expected.to be_included } + end + + context 'when using except' do + let(:attributes) { { name: 'rspec', except: { refs: %w[deploy master] } } } + + it { is_expected.not_to be_included } + end + end + + context 'when keyword policy matches' do + context 'when using only' do + let(:attributes) { { name: 'rspec', only: { refs: ['branches'] } } } + + it { is_expected.to be_included } + end + + context 'when using except' do + let(:attributes) { { name: 'rspec', except: { refs: ['branches'] } } } + + it { is_expected.not_to be_included } + end + end + + context 'when keyword policy does not match' do + context 'when using only' do + let(:attributes) { { name: 'rspec', only: { refs: ['tags'] } } } + + it { is_expected.not_to be_included } + end + + context 'when using except' do + let(:attributes) { { name: 'rspec', except: { refs: ['tags'] } } } + + it { is_expected.to be_included } + end + end + + context 'when keywords and pipeline source policy matches' do + possibilities = [%w[pushes push], + %w[web web], + %w[triggers trigger], + %w[schedules schedule], + %w[api api], + %w[external external]] + + context 'when using only' do + possibilities.each do |keyword, source| + context "when using keyword `#{keyword}` and source `#{source}`" do + let(:pipeline) do + build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source) + end + + let(:attributes) { { name: 'rspec', only: { refs: [keyword] } } } + + it { is_expected.to be_included } + end + end + end + + context 'when using except' do + possibilities.each do |keyword, source| + context "when using keyword `#{keyword}` and source `#{source}`" do + let(:pipeline) do + build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source) + end + + let(:attributes) { { name: 'rspec', except: { refs: [keyword] } } } + + it { is_expected.not_to be_included } + end + end + end + end + + context 'when keywords and pipeline source does not match' do + possibilities = [%w[pushes web], + %w[web push], + %w[triggers schedule], + %w[schedules external], + %w[api trigger], + %w[external api]] + + context 'when using only' do + possibilities.each do |keyword, source| + context "when using keyword `#{keyword}` and source `#{source}`" do + let(:pipeline) do + build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source) + end + + let(:attributes) { { name: 'rspec', only: { refs: [keyword] } } } + + it { is_expected.not_to be_included } + end + end + end + + context 'when using except' do + possibilities.each do |keyword, source| + context "when using keyword `#{keyword}` and source `#{source}`" do + let(:pipeline) do + build(:ci_empty_pipeline, ref: 'deploy', tag: false, source: source) + end + + let(:attributes) { { name: 'rspec', except: { refs: [keyword] } } } + + it { is_expected.to be_included } + end + end + end + end + + context 'when repository path matches' do + context 'when using only' do + let(:attributes) do + { name: 'rspec', only: { refs: ["branches@#{pipeline.project_full_path}"] } } + end + + it { is_expected.to be_included } + end + + context 'when using except' do + let(:attributes) do + { name: 'rspec', except: { refs: ["branches@#{pipeline.project_full_path}"] } } + end + + it { is_expected.not_to be_included } + end + end + + context 'when repository path does not matches' do + context 'when using only' do + let(:attributes) do + { name: 'rspec', only: { refs: ['branches@fork'] } } + end + + it { is_expected.not_to be_included } + end + + context 'when using except' do + let(:attributes) do + { name: 'rspec', except: { refs: ['branches@fork'] } } + end + + it { is_expected.to be_included } + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb new file mode 100644 index 00000000000..8f0bf40d624 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Seed::Stage do + let(:pipeline) { create(:ci_empty_pipeline) } + + let(:attributes) do + { name: 'test', + index: 0, + builds: [{ name: 'rspec' }, + { name: 'spinach' }, + { name: 'deploy', only: { refs: ['feature'] } }] } + end + + subject do + described_class.new(pipeline, attributes) + end + + describe '#size' do + it 'returns a number of jobs in the stage' do + expect(subject.size).to eq 2 + end + end + + describe '#attributes' do + it 'returns hash attributes of a stage' do + expect(subject.attributes).to be_a Hash + expect(subject.attributes).to include(:name, :project) + end + end + + describe '#included?' do + context 'when it contains builds seeds' do + let(:attributes) do + { name: 'test', + index: 0, + builds: [{ name: 'deploy', only: { refs: ['master'] } }] } + end + + it { is_expected.to be_included } + end + + context 'when it does not contain build seeds' do + let(:attributes) do + { name: 'test', + index: 0, + builds: [{ name: 'deploy', only: { refs: ['feature'] } }] } + end + + it { is_expected.not_to be_included } + end + end + + describe '#seeds' do + it 'returns build seeds' do + expect(subject.seeds).to all(be_a Gitlab::Ci::Pipeline::Seed::Build) + end + + it 'returns build seeds including valid attributes' do + expect(subject.seeds.size).to eq 2 + expect(subject.seeds.map(&:attributes)).to all(include(ref: 'master')) + expect(subject.seeds.map(&:attributes)).to all(include(tag: false)) + expect(subject.seeds.map(&:attributes)).to all(include(project: pipeline.project)) + expect(subject.seeds.map(&:attributes)) + .to all(include(trigger_request: pipeline.trigger_requests.first)) + end + + context 'when a ref is protected' do + before do + allow_any_instance_of(Project).to receive(:protected_for?).and_return(true) + end + + it 'returns protected builds' do + expect(subject.seeds.map(&:attributes)).to all(include(protected: true)) + end + end + + context 'when a ref is not protected' do + before do + allow_any_instance_of(Project).to receive(:protected_for?).and_return(false) + end + + it 'returns unprotected builds' do + expect(subject.seeds.map(&:attributes)).to all(include(protected: false)) + end + end + + it 'filters seeds using only/except policies' do + expect(subject.seeds.map(&:attributes)).to satisfy do |seeds| + seeds.any? { |hash| hash.fetch(:name) == 'rspec' } + end + + expect(subject.seeds.map(&:attributes)).not_to satisfy do |seeds| + seeds.any? { |hash| hash.fetch(:name) == 'deploy' } + end + end + end + + describe '#user=' do + let(:user) { build(:user) } + + it 'assignes relevant pipeline attributes' do + subject.user = user + + expect(subject.seeds.map(&:attributes)).to all(include(user: user)) + end + end + + describe '#to_resource' do + it 'builds a valid stage object with all builds' do + subject.to_resource.save! + + expect(pipeline.reload.stages.count).to eq 1 + expect(pipeline.reload.builds.count).to eq 2 + expect(pipeline.builds).to all(satisfy { |job| job.stage_id.present? }) + expect(pipeline.builds).to all(satisfy { |job| job.pipeline.present? }) + expect(pipeline.builds).to all(satisfy { |job| job.project.present? }) + expect(pipeline.stages) + .to all(satisfy { |stage| stage.pipeline.present? }) + expect(pipeline.stages) + .to all(satisfy { |stage| stage.project.present? }) + end + + it 'can not be persisted without explicit pipeline assignment' do + stage = subject.to_resource + + pipeline.save! + + expect(stage).not_to be_persisted + expect(pipeline.reload.stages.count).to eq 0 + expect(pipeline.reload.builds.count).to eq 0 + end + end +end diff --git a/spec/lib/gitlab/ci/stage/seed_spec.rb b/spec/lib/gitlab/ci/stage/seed_spec.rb deleted file mode 100644 index 3fe8d50c49a..00000000000 --- a/spec/lib/gitlab/ci/stage/seed_spec.rb +++ /dev/null @@ -1,83 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Ci::Stage::Seed do - let(:pipeline) { create(:ci_empty_pipeline) } - - let(:builds) do - [{ name: 'rspec' }, { name: 'spinach' }] - end - - subject do - described_class.new(pipeline, 'test', builds) - end - - describe '#size' do - it 'returns a number of jobs in the stage' do - expect(subject.size).to eq 2 - end - end - - describe '#stage' do - it 'returns hash attributes of a stage' do - expect(subject.stage).to be_a Hash - expect(subject.stage).to include(:name, :project) - end - end - - describe '#builds' do - it 'returns hash attributes of all builds' do - expect(subject.builds.size).to eq 2 - expect(subject.builds).to all(include(ref: 'master')) - expect(subject.builds).to all(include(tag: false)) - expect(subject.builds).to all(include(project: pipeline.project)) - expect(subject.builds) - .to all(include(trigger_request: pipeline.trigger_requests.first)) - end - - context 'when a ref is protected' do - before do - allow_any_instance_of(Project).to receive(:protected_for?).and_return(true) - end - - it 'returns protected builds' do - expect(subject.builds).to all(include(protected: true)) - end - end - - context 'when a ref is unprotected' do - before do - allow_any_instance_of(Project).to receive(:protected_for?).and_return(false) - end - - it 'returns unprotected builds' do - expect(subject.builds).to all(include(protected: false)) - end - end - end - - describe '#user=' do - let(:user) { build(:user) } - - it 'assignes relevant pipeline attributes' do - subject.user = user - - expect(subject.builds).to all(include(user: user)) - end - end - - describe '#create!' do - it 'creates all stages and builds' do - subject.create! - - expect(pipeline.reload.stages.count).to eq 1 - expect(pipeline.reload.builds.count).to eq 2 - expect(pipeline.builds).to all(satisfy { |job| job.stage_id.present? }) - expect(pipeline.builds).to all(satisfy { |job| job.pipeline.present? }) - expect(pipeline.builds).to all(satisfy { |job| job.project.present? }) - expect(pipeline.stages) - .to all(satisfy { |stage| stage.pipeline.present? }) - expect(pipeline.stages) - .to all(satisfy { |stage| stage.project.present? }) - end - end -end diff --git a/spec/lib/gitlab/ci/trace/http_io_spec.rb b/spec/lib/gitlab/ci/trace/http_io_spec.rb new file mode 100644 index 00000000000..5474e2f518c --- /dev/null +++ b/spec/lib/gitlab/ci/trace/http_io_spec.rb @@ -0,0 +1,315 @@ +require 'spec_helper' + +describe Gitlab::Ci::Trace::HttpIO do + include HttpIOHelpers + + let(:http_io) { described_class.new(url, size) } + let(:url) { remote_trace_url } + let(:size) { remote_trace_size } + + describe '#close' do + subject { http_io.close } + + it { is_expected.to be_nil } + end + + describe '#binmode' do + subject { http_io.binmode } + + it { is_expected.to be_nil } + end + + describe '#binmode?' do + subject { http_io.binmode? } + + it { is_expected.to be_truthy } + end + + describe '#path' do + subject { http_io.path } + + it { is_expected.to be_nil } + end + + describe '#url' do + subject { http_io.url } + + it { is_expected.to eq(url) } + end + + describe '#seek' do + subject { http_io.seek(pos, where) } + + context 'when moves pos to end of the file' do + let(:pos) { 0 } + let(:where) { IO::SEEK_END } + + it { is_expected.to eq(size) } + end + + context 'when moves pos to middle of the file' do + let(:pos) { size / 2 } + let(:where) { IO::SEEK_SET } + + it { is_expected.to eq(size / 2) } + end + + context 'when moves pos around' do + it 'matches the result' do + expect(http_io.seek(0)).to eq(0) + expect(http_io.seek(100, IO::SEEK_CUR)).to eq(100) + expect { http_io.seek(size + 1, IO::SEEK_CUR) }.to raise_error('new position is outside of file') + end + end + end + + describe '#eof?' do + subject { http_io.eof? } + + context 'when current pos is at end of the file' do + before do + http_io.seek(size, IO::SEEK_SET) + end + + it { is_expected.to be_truthy } + end + + context 'when current pos is not at end of the file' do + before do + http_io.seek(0, IO::SEEK_SET) + end + + it { is_expected.to be_falsey } + end + end + + describe '#each_line' do + subject { http_io.each_line } + + let(:string_io) { StringIO.new(remote_trace_body) } + + before do + stub_remote_trace_206 + end + + it 'yields lines' do + expect { |b| http_io.each_line(&b) }.to yield_successive_args(*string_io.each_line.to_a) + end + + context 'when buckets on GCS' do + context 'when BUFFER_SIZE is larger than file size' do + before do + stub_remote_trace_200 + set_larger_buffer_size_than(size) + end + + it 'calls get_chunk only once' do + expect_any_instance_of(Net::HTTP).to receive(:request).once.and_call_original + + http_io.each_line { |line| } + end + end + end + end + + describe '#read' do + subject { http_io.read(length) } + + context 'when there are no network issue' do + before do + stub_remote_trace_206 + end + + context 'when read whole size' do + let(:length) { nil } + + context 'when BUFFER_SIZE is smaller than file size' do + before do + set_smaller_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to eq(remote_trace_body) + end + end + + context 'when BUFFER_SIZE is larger than file size' do + before do + set_larger_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to eq(remote_trace_body) + end + end + end + + context 'when read only first 100 bytes' do + let(:length) { 100 } + + context 'when BUFFER_SIZE is smaller than file size' do + before do + set_smaller_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to eq(remote_trace_body[0, length]) + end + end + + context 'when BUFFER_SIZE is larger than file size' do + before do + set_larger_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to eq(remote_trace_body[0, length]) + end + end + end + + context 'when tries to read oversize' do + let(:length) { size + 1000 } + + context 'when BUFFER_SIZE is smaller than file size' do + before do + set_smaller_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to eq(remote_trace_body) + end + end + + context 'when BUFFER_SIZE is larger than file size' do + before do + set_larger_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to eq(remote_trace_body) + end + end + end + + context 'when tries to read 0 bytes' do + let(:length) { 0 } + + context 'when BUFFER_SIZE is smaller than file size' do + before do + set_smaller_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to be_empty + end + end + + context 'when BUFFER_SIZE is larger than file size' do + before do + set_larger_buffer_size_than(size) + end + + it 'reads a trace' do + is_expected.to be_empty + end + end + end + end + + context 'when there is anetwork issue' do + let(:length) { nil } + + before do + stub_remote_trace_500 + end + + it 'reads a trace' do + expect { subject }.to raise_error(Gitlab::Ci::Trace::HttpIO::FailedToGetChunkError) + end + end + end + + describe '#readline' do + subject { http_io.readline } + + let(:string_io) { StringIO.new(remote_trace_body) } + + before do + stub_remote_trace_206 + end + + shared_examples 'all line matching' do + it 'reads a line' do + (0...remote_trace_body.lines.count).each do + expect(http_io.readline).to eq(string_io.readline) + end + end + end + + context 'when there is anetwork issue' do + let(:length) { nil } + + before do + stub_remote_trace_500 + end + + it 'reads a trace' do + expect { subject }.to raise_error(Gitlab::Ci::Trace::HttpIO::FailedToGetChunkError) + end + end + + context 'when BUFFER_SIZE is smaller than file size' do + before do + set_smaller_buffer_size_than(size) + end + + it_behaves_like 'all line matching' + end + + context 'when BUFFER_SIZE is larger than file size' do + before do + set_larger_buffer_size_than(size) + end + + it_behaves_like 'all line matching' + end + + context 'when pos is at middle of the file' do + before do + set_smaller_buffer_size_than(size) + + http_io.seek(size / 2) + string_io.seek(size / 2) + end + + it 'reads from pos' do + expect(http_io.readline).to eq(string_io.readline) + end + end + end + + describe '#write' do + subject { http_io.write(nil) } + + it { expect { subject }.to raise_error(NotImplementedError) } + end + + describe '#truncate' do + subject { http_io.truncate(nil) } + + it { expect { subject }.to raise_error(NotImplementedError) } + end + + describe '#flush' do + subject { http_io.flush } + + it { expect { subject }.to raise_error(NotImplementedError) } + end + + describe '#present?' do + subject { http_io.present? } + + it { is_expected.to be_truthy } + end +end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 448c6fb57dd..3a9371ed2e8 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -510,6 +510,28 @@ describe Gitlab::Ci::Trace do it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid end + + context 'when there is a validation error on Ci::Build' do + before do + allow_any_instance_of(Ci::Build).to receive(:save).and_return(false) + allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages) + .and_return(%w[Error Error]) + end + + context "when erase old trace with 'save'" do + before do + build.send(:write_attribute, :trace, nil) + build.save + end + + it 'old trace is not deleted' do + build.reload + expect(build.trace.raw).to eq(trace_content) + end + end + + it_behaves_like 'archive trace in database' + end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index f83f932e61e..fbc2af29b98 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -18,6 +18,34 @@ module Gitlab describe '#build_attributes' do subject { described_class.new(config).build_attributes(:rspec) } + describe 'attributes list' do + let(:config) do + YAML.dump( + before_script: ['pwd'], + rspec: { script: 'rspec' } + ) + end + + it 'returns valid build attributes' do + expect(subject).to eq({ + stage: "test", + stage_idx: 1, + name: "rspec", + commands: "pwd\nrspec", + coverage_regex: nil, + tag_list: [], + options: { + before_script: ["pwd"], + script: ["rspec"] + }, + allow_failure: false, + when: "on_success", + environment: nil, + yaml_variables: [] + }) + end + end + describe 'coverage entry' do describe 'code coverage regexp' do let(:config) do @@ -105,512 +133,118 @@ module Gitlab end end - describe '#stage_seeds' do - context 'when no refs policy is specified' do - let(:config) do - YAML.dump(production: { stage: 'deploy', script: 'cap prod' }, - rspec: { stage: 'test', script: 'rspec' }, - spinach: { stage: 'test', script: 'spinach' }) - end - - let(:pipeline) { create(:ci_empty_pipeline) } - - it 'correctly fabricates a stage seeds object' do - seeds = subject.stage_seeds(pipeline) - - expect(seeds.size).to eq 2 - expect(seeds.first.stage[:name]).to eq 'test' - expect(seeds.second.stage[:name]).to eq 'deploy' - expect(seeds.first.builds.dig(0, :name)).to eq 'rspec' - expect(seeds.first.builds.dig(1, :name)).to eq 'spinach' - expect(seeds.second.builds.dig(0, :name)).to eq 'production' - end - end - - context 'when refs policy is specified' do - let(:config) do - YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, - spinach: { stage: 'test', script: 'spinach', only: ['tags'] }) - end - - let(:pipeline) do - create(:ci_empty_pipeline, ref: 'feature', tag: true) - end - - it 'returns stage seeds only assigned to master to master' do - seeds = subject.stage_seeds(pipeline) - - expect(seeds.size).to eq 1 - expect(seeds.first.stage[:name]).to eq 'test' - expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' - end - end - - context 'when source policy is specified' do - let(:config) do - YAML.dump(production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] }, - spinach: { stage: 'test', script: 'spinach', only: ['schedules'] }) - end - - let(:pipeline) do - create(:ci_empty_pipeline, source: :schedule) - end - - it 'returns stage seeds only assigned to schedules' do - seeds = subject.stage_seeds(pipeline) - - expect(seeds.size).to eq 1 - expect(seeds.first.stage[:name]).to eq 'test' - expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' - end + describe '#stages_attributes' do + let(:config) do + YAML.dump( + rspec: { script: 'rspec', stage: 'test', only: ['branches'] }, + prod: { script: 'cap prod', stage: 'deploy', only: ['tags'] } + ) end - context 'when kubernetes policy is specified' do - let(:config) do - YAML.dump( - spinach: { stage: 'test', script: 'spinach' }, - production: { - stage: 'deploy', - script: 'cap', - only: { kubernetes: 'active' } - } - ) - end - - context 'when kubernetes is active' do - shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do - it 'returns seeds for kubernetes dependent job' do - seeds = subject.stage_seeds(pipeline) - - expect(seeds.size).to eq 2 - expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' - expect(seeds.second.builds.dig(0, :name)).to eq 'production' - end - end - - context 'when user configured kubernetes from Integration > Kubernetes' do - let(:project) { create(:kubernetes_project) } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } - - it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' - end - - context 'when user configured kubernetes from CI/CD > Clusters' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:project) { cluster.project } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } - - it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' - end - end - - context 'when kubernetes is not active' do - it 'does not return seeds for kubernetes dependent job' do - seeds = subject.stage_seeds(pipeline) - - expect(seeds.size).to eq 1 - expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' - end - end + let(:attributes) do + [{ name: "build", + index: 0, + builds: [] }, + { name: "test", + index: 1, + builds: + [{ stage_idx: 1, + stage: "test", + commands: "rspec", + tag_list: [], + name: "rspec", + allow_failure: false, + when: "on_success", + environment: nil, + coverage_regex: nil, + yaml_variables: [], + options: { script: ["rspec"] }, + only: { refs: ["branches"] }, + except: {} }] }, + { name: "deploy", + index: 2, + builds: + [{ stage_idx: 2, + stage: "deploy", + commands: "cap prod", + tag_list: [], + name: "prod", + allow_failure: false, + when: "on_success", + environment: nil, + coverage_regex: nil, + yaml_variables: [], + options: { script: ["cap prod"] }, + only: { refs: ["tags"] }, + except: {} }] }] + end + + it 'returns stages seed attributes' do + expect(subject.stages_attributes).to eq attributes end end - describe "#pipeline_stage_builds" do - let(:type) { 'test' } + describe 'only / except policies validations' do + context 'when `only` has an invalid value' do + let(:config) { { rspec: { script: "rspec", type: "test", only: only } } } + let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) } - it "returns builds if no branch specified" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec" } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).first).to eq({ - stage: "test", - stage_idx: 1, - name: "rspec", - commands: "pwd\nrspec", - coverage_regex: nil, - tag_list: [], - options: { - before_script: ["pwd"], - script: ["rspec"] - }, - allow_failure: false, - when: "on_success", - environment: nil, - yaml_variables: [] - }) - end - - describe 'only' do - it "does not return builds if only has another branch" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", only: ["deploy"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(0) - end - - it "does not return builds if only has regexp with another branch" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", only: ["/^deploy$/"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(0) - end - - it "returns builds if only has specified this branch" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", only: ["master"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1) - end - - it "returns builds if only has a list of branches including specified" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: %w(master deploy) } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1) - end - - it "returns builds if only has a branches keyword specified" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: ["branches"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1) - end - - it "does not return builds if only has a tags keyword" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: ["tags"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0) - end + context 'when it is integer' do + let(:only) { 1 } - it "returns builds if only has special keywords specified and source matches" do - possibilities = [{ keyword: 'pushes', source: 'push' }, - { keyword: 'web', source: 'web' }, - { keyword: 'triggers', source: 'trigger' }, - { keyword: 'schedules', source: 'schedule' }, - { keyword: 'api', source: 'api' }, - { keyword: 'external', source: 'external' }] - - possibilities.each do |possibility| - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: [possibility[:keyword]] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(1) + it do + expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:only has to be either an array of conditions or a hash') end end - it "does not return builds if only has special keywords specified and source doesn't match" do - possibilities = [{ keyword: 'pushes', source: 'web' }, - { keyword: 'web', source: 'push' }, - { keyword: 'triggers', source: 'schedule' }, - { keyword: 'schedules', source: 'external' }, - { keyword: 'api', source: 'trigger' }, - { keyword: 'external', source: 'api' }] - - possibilities.each do |possibility| - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: [possibility[:keyword]] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) + context 'when it is an array of integers' do + let(:only) { [1, 1] } - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(0) + it do + expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:only config should be an array of strings or regexps') end end - it "returns builds if only has current repository path" do - seed_pipeline = pipeline(ref: 'deploy') - - config = YAML.dump({ - before_script: ["pwd"], - rspec: { - script: "rspec", - type: type, - only: ["branches@#{seed_pipeline.project_full_path}"] - } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) + context 'when it is invalid regex' do + let(:only) { ["/*invalid/"] } - expect(config_processor.pipeline_stage_builds(type, seed_pipeline).size).to eq(1) - end - - it "does not return builds if only has different repository path" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: ["branches@fork"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0) - end - - it "returns build only for specified type" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: "test", only: %w(master deploy) }, - staging: { script: "deploy", type: "deploy", only: %w(master deploy) }, - production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds("deploy", pipeline(ref: "deploy")).size).to eq(2) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "deploy")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("deploy", pipeline(ref: "master")).size).to eq(1) - end - - context 'for invalid value' do - let(:config) { { rspec: { script: "rspec", type: "test", only: only } } } - let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) } - - context 'when it is integer' do - let(:only) { 1 } - - it do - expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, - 'jobs:rspec:only has to be either an array of conditions or a hash') - end - end - - context 'when it is an array of integers' do - let(:only) { [1, 1] } - - it do - expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, - 'jobs:rspec:only config should be an array of strings or regexps') - end - end - - context 'when it is invalid regex' do - let(:only) { ["/*invalid/"] } - - it do - expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, - 'jobs:rspec:only config should be an array of strings or regexps') - end + it do + expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:only config should be an array of strings or regexps') end end end - describe 'except' do - it "returns builds if except has another branch" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", except: ["deploy"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1) - end - - it "returns builds if except has regexp with another branch" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", except: ["/^deploy$/"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) + context 'when `except` has an invalid value' do + let(:config) { { rspec: { script: "rspec", except: except } } } + let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) } - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(1) - end - - it "does not return builds if except has specified this branch" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", except: ["master"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "master")).size).to eq(0) - end - - it "does not return builds if except has a list of branches including specified" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: %w(master deploy) } - }) + context 'when it is integer' do + let(:except) { 1 } - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0) - end - - it "does not return builds if except has a branches keyword specified" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: ["branches"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(0) - end - - it "returns builds if except has a tags keyword" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: ["tags"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1) - end - - it "does not return builds if except has special keywords specified and source matches" do - possibilities = [{ keyword: 'pushes', source: 'push' }, - { keyword: 'web', source: 'web' }, - { keyword: 'triggers', source: 'trigger' }, - { keyword: 'schedules', source: 'schedule' }, - { keyword: 'api', source: 'api' }, - { keyword: 'external', source: 'external' }] - - possibilities.each do |possibility| - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: [possibility[:keyword]] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(0) + it do + expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:except has to be either an array of conditions or a hash') end end - it "returns builds if except has special keywords specified and source doesn't match" do - possibilities = [{ keyword: 'pushes', source: 'web' }, - { keyword: 'web', source: 'push' }, - { keyword: 'triggers', source: 'schedule' }, - { keyword: 'schedules', source: 'external' }, - { keyword: 'api', source: 'trigger' }, - { keyword: 'external', source: 'api' }] - - possibilities.each do |possibility| - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: [possibility[:keyword]] } - }) + context 'when it is an array of integers' do + let(:except) { [1, 1] } - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: 'deploy', tag: false, source: possibility[:source])).size).to eq(1) + it do + expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:except config should be an array of strings or regexps') end end - it "does not return builds if except has current repository path" do - seed_pipeline = pipeline(ref: 'deploy') - - config = YAML.dump({ - before_script: ["pwd"], - rspec: { - script: "rspec", - type: type, - except: ["branches@#{seed_pipeline.project_full_path}"] - } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, seed_pipeline).size).to eq(0) - end - - it "returns builds if except has different repository path" do - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: ["branches@fork"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds(type, pipeline(ref: "deploy")).size).to eq(1) - end - - it "returns build except specified type" do - master_pipeline = pipeline(ref: 'master') - test_pipeline = pipeline(ref: 'test') - deploy_pipeline = pipeline(ref: 'deploy') - - config = YAML.dump({ - before_script: ["pwd"], - rspec: { script: "rspec", type: "test", except: ["master", "deploy", "test@#{test_pipeline.project_full_path}"] }, - staging: { script: "deploy", type: "deploy", except: ["master"] }, - production: { script: "deploy", type: "deploy", except: ["master@#{master_pipeline.project_full_path}"] } - }) - - config_processor = Gitlab::Ci::YamlProcessor.new(config) - - expect(config_processor.pipeline_stage_builds("deploy", deploy_pipeline).size).to eq(2) - expect(config_processor.pipeline_stage_builds("test", test_pipeline).size).to eq(0) - expect(config_processor.pipeline_stage_builds("deploy", master_pipeline).size).to eq(0) - end - - context 'for invalid value' do - let(:config) { { rspec: { script: "rspec", except: except } } } - let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) } + context 'when it is invalid regex' do + let(:except) { ["/*invalid/"] } - context 'when it is integer' do - let(:except) { 1 } - - it do - expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, - 'jobs:rspec:except has to be either an array of conditions or a hash') - end - end - - context 'when it is an array of integers' do - let(:except) { [1, 1] } - - it do - expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, - 'jobs:rspec:except config should be an array of strings or regexps') - end - end - - context 'when it is invalid regex' do - let(:except) { ["/*invalid/"] } - - it do - expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, - 'jobs:rspec:except config should be an array of strings or regexps') - end + it do + expect { processor }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, + 'jobs:rspec:except config should be an array of strings or regexps') end end end @@ -620,7 +254,7 @@ module Gitlab let(:config_data) { YAML.dump(config) } let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config_data) } - subject { config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first } + subject { config_processor.stage_builds_attributes('test').first } describe "before_script" do context "in global context" do @@ -703,8 +337,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({ + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first).to eq({ stage: "test", stage_idx: 1, name: "rspec", @@ -738,8 +372,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({ + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first).to eq({ stage: "test", stage_idx: 1, name: "rspec", @@ -771,8 +405,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({ + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first).to eq({ stage: "test", stage_idx: 1, name: "rspec", @@ -800,8 +434,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({ + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first).to eq({ stage: "test", stage_idx: 1, name: "rspec", @@ -946,8 +580,8 @@ module Gitlab }) config_processor = Gitlab::Ci::YamlProcessor.new(config) + builds = config_processor.stage_builds_attributes("test") - builds = config_processor.pipeline_stage_builds("test", pipeline(ref: "master")) expect(builds.size).to eq(1) expect(builds.first[:when]).to eq(when_state) end @@ -978,8 +612,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, key: 'key', @@ -997,8 +631,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( paths: ["logs/", "binaries/"], untracked: true, key: 'key', @@ -1017,8 +651,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first[:options][:cache]).to eq( + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first[:options][:cache]).to eq( paths: ["test/"], untracked: false, key: 'local', @@ -1046,8 +680,8 @@ module Gitlab config_processor = Gitlab::Ci::YamlProcessor.new(config) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).size).to eq(1) - expect(config_processor.pipeline_stage_builds("test", pipeline(ref: "master")).first).to eq({ + expect(config_processor.stage_builds_attributes("test").size).to eq(1) + expect(config_processor.stage_builds_attributes("test").first).to eq({ stage: "test", stage_idx: 1, name: "rspec", @@ -1083,8 +717,8 @@ module Gitlab }) config_processor = Gitlab::Ci::YamlProcessor.new(config) + builds = config_processor.stage_builds_attributes("test") - builds = config_processor.pipeline_stage_builds("test", pipeline(ref: "master")) expect(builds.size).to eq(1) expect(builds.first[:options][:artifacts][:when]).to eq(when_state) end @@ -1099,7 +733,7 @@ module Gitlab end let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) } - let(:builds) { processor.pipeline_stage_builds('deploy', pipeline(ref: 'master')) } + let(:builds) { processor.stage_builds_attributes('deploy') } context 'when a production environment is specified' do let(:environment) { 'production' } @@ -1256,7 +890,7 @@ module Gitlab describe "Hidden jobs" do let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) } - subject { config_processor.pipeline_stage_builds("test", pipeline(ref: "master")) } + subject { config_processor.stage_builds_attributes("test") } shared_examples 'hidden_job_handling' do it "doesn't create jobs that start with dot" do @@ -1304,7 +938,7 @@ module Gitlab describe "YAML Alias/Anchor" do let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) } - subject { config_processor.pipeline_stage_builds("build", pipeline(ref: "master")) } + subject { config_processor.stage_builds_attributes("build") } shared_examples 'job_templates_handling' do it "is correctly supported for jobs" do @@ -1344,13 +978,13 @@ module Gitlab context 'when template is a job' do let(:config) do - <<EOT -job1: &JOBTMPL - stage: build - script: execute-script-for-job + <<~EOT + job1: &JOBTMPL + stage: build + script: execute-script-for-job -job2: *JOBTMPL -EOT + job2: *JOBTMPL + EOT end it_behaves_like 'job_templates_handling' @@ -1358,15 +992,15 @@ EOT context 'when template is a hidden job' do let(:config) do - <<EOT -.template: &JOBTMPL - stage: build - script: execute-script-for-job + <<~EOT + .template: &JOBTMPL + stage: build + script: execute-script-for-job -job1: *JOBTMPL + job1: *JOBTMPL -job2: *JOBTMPL -EOT + job2: *JOBTMPL + EOT end it_behaves_like 'job_templates_handling' @@ -1374,18 +1008,18 @@ EOT context 'when job adds its own keys to a template definition' do let(:config) do - <<EOT -.template: &JOBTMPL - stage: build - -job1: - <<: *JOBTMPL - script: execute-script-for-job - -job2: - <<: *JOBTMPL - script: execute-script-for-job -EOT + <<~EOT + .template: &JOBTMPL + stage: build + + job1: + <<: *JOBTMPL + script: execute-script-for-job + + job2: + <<: *JOBTMPL + script: execute-script-for-job + EOT end it_behaves_like 'job_templates_handling' @@ -1724,10 +1358,6 @@ EOT it { is_expected.to be_nil } end end - - def pipeline(**attributes) - build_stubbed(:ci_empty_pipeline, **attributes) - end end end end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 1de3a14b809..280f799f2ab 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -67,17 +67,35 @@ describe Gitlab::Database::MigrationHelpers do model.add_concurrent_index(:users, :foo, unique: true) end + + it 'does nothing if the index exists already' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(true) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :foo, unique: true) + end end context 'using MySQL' do - it 'creates a regular index' do - expect(Gitlab::Database).to receive(:postgresql?).and_return(false) + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + it 'creates a regular index' do expect(model).to receive(:add_index) .with(:users, :foo, {}) model.add_concurrent_index(:users, :foo) end + + it 'does nothing if the index exists already' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { unique: true }).and_return(true) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :foo, unique: true) + end end end @@ -95,6 +113,7 @@ describe Gitlab::Database::MigrationHelpers do context 'outside a transaction' do before do allow(model).to receive(:transaction_open?).and_return(false) + allow(model).to receive(:index_exists?).and_return(true) end context 'using PostgreSQL' do @@ -103,18 +122,41 @@ describe Gitlab::Database::MigrationHelpers do allow(model).to receive(:disable_statement_timeout) end - it 'removes the index concurrently by column name' do - expect(model).to receive(:remove_index) - .with(:users, { algorithm: :concurrently, column: :foo }) + describe 'by column name' do + it 'removes the index concurrently' do + expect(model).to receive(:remove_index) + .with(:users, { algorithm: :concurrently, column: :foo }) - model.remove_concurrent_index(:users, :foo) + model.remove_concurrent_index(:users, :foo) + end + + it 'does nothing if the index does not exist' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(false) + expect(model).not_to receive(:remove_index) + + model.remove_concurrent_index(:users, :foo, unique: true) + end end - it 'removes the index concurrently by index name' do - expect(model).to receive(:remove_index) - .with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + describe 'by index name' do + before do + allow(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(true) + end + + it 'removes the index concurrently by index name' do + expect(model).to receive(:remove_index) + .with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end + + it 'does nothing if the index does not exist' do + expect(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(false) + expect(model).not_to receive(:remove_index) - model.remove_concurrent_index_by_name(:users, "index_x_by_y") + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end end end @@ -141,6 +183,10 @@ describe Gitlab::Database::MigrationHelpers do end describe '#add_concurrent_foreign_key' do + before do + allow(model).to receive(:foreign_key_exists?).and_return(false) + end + context 'inside a transaction' do it 'raises an error' do expect(model).to receive(:transaction_open?).and_return(true) @@ -157,14 +203,23 @@ describe Gitlab::Database::MigrationHelpers do end context 'using MySQL' do - it 'creates a regular foreign key' do + before do allow(Gitlab::Database).to receive(:mysql?).and_return(true) + end + it 'creates a regular foreign key' do expect(model).to receive(:add_foreign_key) .with(:projects, :users, column: :user_id, on_delete: :cascade) model.add_concurrent_foreign_key(:projects, :users, column: :user_id) end + + it 'does not create a foreign key if it exists already' do + expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true) + expect(model).not_to receive(:add_foreign_key) + + model.add_concurrent_foreign_key(:projects, :users, column: :user_id) + end end context 'using PostgreSQL' do @@ -189,6 +244,14 @@ describe Gitlab::Database::MigrationHelpers do column: :user_id, on_delete: :nullify) end + + it 'does not create a foreign key if it exists already' do + expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true) + expect(model).not_to receive(:execute).with(/ADD CONSTRAINT/) + expect(model).to receive(:execute).with(/VALIDATE CONSTRAINT/) + + model.add_concurrent_foreign_key(:projects, :users, column: :user_id) + end end end end @@ -203,6 +266,29 @@ describe Gitlab::Database::MigrationHelpers do end end + describe '#foreign_key_exists?' do + before do + key = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(:projects, :users, { column: :non_standard_id }) + allow(model).to receive(:foreign_keys).with(:projects).and_return([key]) + end + + it 'finds existing foreign keys by column' do + expect(model.foreign_key_exists?(:projects, :users, column: :non_standard_id)).to be_truthy + end + + it 'finds existing foreign keys by target table only' do + expect(model.foreign_key_exists?(:projects, :users)).to be_truthy + end + + it 'compares by column name if given' do + expect(model.foreign_key_exists?(:projects, :users, column: :user_id)).to be_falsey + end + + it 'compares by target if no column given' do + expect(model.foreign_key_exists?(:projects, :other_table)).to be_falsey + end + end + describe '#disable_statement_timeout' do context 'using PostgreSQL' do it 'disables statement timeouts' do @@ -1125,4 +1211,33 @@ describe Gitlab::Database::MigrationHelpers do expect(model.perform_background_migration_inline?).to eq(false) end end + + describe '#index_exists_by_name?' do + it 'returns true if an index exists' do + expect(model.index_exists_by_name?(:projects, 'index_projects_on_path')) + .to be_truthy + end + + it 'returns false if the index does not exist' do + expect(model.index_exists_by_name?(:projects, 'this_does_not_exist')) + .to be_falsy + end + + context 'when an index with a function exists', :postgresql do + before do + ActiveRecord::Base.connection.execute( + 'CREATE INDEX test_index ON projects (LOWER(path));' + ) + end + + after do + 'DROP INDEX IF EXISTS test_index;' + end + + it 'returns true if an index exists' do + expect(model.index_exists_by_name?(:projects, 'test_index')) + .to be_truthy + end + end + end end diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 9204ea37963..0c2e18c268a 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -455,5 +455,17 @@ describe Gitlab::Diff::File do expect(diff_file.size).to be_zero end end + + describe '#different_type?' do + it 'returns false' do + expect(diff_file).not_to be_different_type + end + end + + describe '#content_changed?' do + it 'returns false' do + expect(diff_file).not_to be_content_changed + end + end end end diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 83d431a7458..e68c9850f6b 100644 --- a/spec/lib/gitlab/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -161,6 +161,11 @@ describe Gitlab::EncodingHelper do 'removes invalid bytes from ASCII-8bit encoded multibyte string.', "Lorem ipsum\xC3\n dolor sit amet, xy\xC3\xA0y\xC3\xB9abcd\xC3\xB9efg".force_encoding('ASCII-8BIT'), "Lorem ipsum\n dolor sit amet, xyà yùabcdùefg" + ], + [ + 'handles UTF-16BE encoded strings', + "\xFE\xFF\x00\x41".force_encoding('ASCII-8BIT'), # An "A" prepended with UTF-16 BOM + "\xEF\xBB\xBFA" # An "A" prepended with UTF-8 BOM ] ].each do |description, test_string, xpect| it description do diff --git a/spec/lib/gitlab/git/conflict/file_spec.rb b/spec/lib/gitlab/git/conflict/file_spec.rb new file mode 100644 index 00000000000..afed6c32af6 --- /dev/null +++ b/spec/lib/gitlab/git/conflict/file_spec.rb @@ -0,0 +1,50 @@ +# coding: utf-8 +require 'spec_helper' + +describe Gitlab::Git::Conflict::File do + let(:conflict) { { theirs: { path: 'foo', mode: 33188 }, ours: { path: 'foo', mode: 33188 } } } + let(:invalid_content) { described_class.new(nil, nil, conflict, "a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) } + let(:valid_content) { described_class.new(nil, nil, conflict, "Espa\xC3\xB1a".force_encoding(Encoding::ASCII_8BIT)) } + + describe '#lines' do + context 'when the content contains non-UTF-8 characters' do + it 'raises UnsupportedEncoding' do + expect { invalid_content.lines } + .to raise_error(described_class::UnsupportedEncoding) + end + end + + context 'when the content can be converted to UTF-8' do + it 'sets lines to the lines' do + expect(valid_content.lines).to eq([{ + full_line: 'España', + type: nil, + line_obj_index: 0, + line_old: 1, + line_new: 1 + }]) + end + + it 'sets the type to text' do + expect(valid_content.type).to eq('text') + end + end + end + + describe '#content' do + context 'when the content contains non-UTF-8 characters' do + it 'raises UnsupportedEncoding' do + expect { invalid_content.content } + .to raise_error(described_class::UnsupportedEncoding) + end + end + + context 'when the content can be converted to UTF-8' do + it 'returns a valid UTF-8 string' do + expect(valid_content.content).to eq('España') + expect(valid_content.content).to be_valid_encoding + expect(valid_content.content.encoding).to eq(Encoding::UTF_8) + end + end + end +end diff --git a/spec/lib/gitlab/git/conflict/parser_spec.rb b/spec/lib/gitlab/git/conflict/parser_spec.rb index 7b035a381f1..29a1702a1c6 100644 --- a/spec/lib/gitlab/git/conflict/parser_spec.rb +++ b/spec/lib/gitlab/git/conflict/parser_spec.rb @@ -212,13 +212,6 @@ CONFLICT .not_to raise_error end end - - context 'when the file contains non-UTF-8 characters' do - it 'raises UnsupportedEncoding' do - expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) } - .to raise_error(Gitlab::Git::Conflict::Parser::UnsupportedEncoding) - end - end end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 54ada3e423f..0e315b3f49e 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -604,17 +604,20 @@ describe Gitlab::Git::Repository, seed_helper: true do shared_examples 'returning the right branches' do let(:head_id) { repository.rugged.head.target.oid } let(:new_branch) { head_id } + let(:utf8_branch) { 'branch-é' } before do repository.create_branch(new_branch, 'master') + repository.create_branch(utf8_branch, 'master') end after do repository.delete_branch(new_branch) + repository.delete_branch(utf8_branch) end it 'displays that branch' do - expect(repository.branch_names_contains_sha(head_id)).to include('master', new_branch) + expect(repository.branch_names_contains_sha(head_id)).to include('master', new_branch, utf8_branch) end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 6f07e423c1b..f8f09d29c73 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -240,14 +240,21 @@ describe Gitlab::GitAccess do end shared_examples 'check_project_moved' do - it 'enqueues a redirected message' do + it 'enqueues a redirected message for pushing' do push_access_check expect(Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id)).not_to be_nil end + + it 'allows push and pull access' do + aggregate_failures do + expect { push_access_check }.not_to raise_error + expect { pull_access_check }.not_to raise_error + end + end end - describe '#check_project_moved!', :clean_gitlab_redis_shared_state do + describe '#add_project_moved_message!', :clean_gitlab_redis_shared_state do before do project.add_master(user) end @@ -261,62 +268,18 @@ describe Gitlab::GitAccess do end end - context 'when a permanent redirect and ssh protocol' do + context 'with a redirect and ssh protocol' do let(:redirected_path) { 'some/other-path' } - before do - allow_any_instance_of(Gitlab::Checks::ProjectMoved).to receive(:permanent_redirect?).and_return(true) - end - - it 'allows push and pull access' do - aggregate_failures do - expect { push_access_check }.not_to raise_error - end - end - it_behaves_like 'check_project_moved' end - context 'with a permanent redirect and http protocol' do + context 'with a redirect and http protocol' do let(:redirected_path) { 'some/other-path' } let(:protocol) { 'http' } - before do - allow_any_instance_of(Gitlab::Checks::ProjectMoved).to receive(:permanent_redirect?).and_return(true) - end - - it 'allows_push and pull access' do - aggregate_failures do - expect { push_access_check }.not_to raise_error - end - end - it_behaves_like 'check_project_moved' end - - context 'with a temporal redirect and ssh protocol' do - let(:redirected_path) { 'some/other-path' } - - it 'blocks push and pull access' do - aggregate_failures do - expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /Project '#{redirected_path}' was moved to '#{project.full_path}'/) - expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.ssh_url_to_repo}/) - - expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /Project '#{redirected_path}' was moved to '#{project.full_path}'/) - expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.ssh_url_to_repo}/) - end - end - end - - context 'with a temporal redirect and http protocol' do - let(:redirected_path) { 'some/other-path' } - let(:protocol) { 'http' } - - it 'does not allow to push and pull access' do - expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/) - expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/) - end - end end describe '#check_authentication_abilities!' do diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb index 872377c93d8..f03c7e3f04b 100644 --- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb @@ -58,4 +58,14 @@ describe Gitlab::GitalyClient::RemoteService do client.update_remote_mirror(ref_name, only_branches_matching) end end + + describe '.exists?' do + context "when the remote doesn't exist" do + let(:url) { 'https://gitlab.com/gitlab-org/ik-besta-niet-of-ik-word-geplaagd.git' } + + it 'returns false' do + expect(described_class.exists?(url)).to be(false) + end + end + end end diff --git a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb index 5bedfc79dd3..1f0f1fdd7da 100644 --- a/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/repository_importer_spec.rb @@ -38,8 +38,12 @@ describe Gitlab::GithubImport::Importer::RepositoryImporter do expect(project) .to receive(:wiki_repository_exists?) .and_return(false) + expect(Gitlab::GitalyClient::RemoteService) + .to receive(:exists?) + .with("foo.wiki.git") + .and_return(true) - expect(importer.import_wiki?).to eq(true) + expect(importer.import_wiki?).to be(true) end it 'returns false if the GitHub wiki is disabled' do diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index 4c1ca4349ea..9dcf272d25e 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -26,7 +26,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do let(:storages_paths) do { - default: { path: tmp_dir } + default: Gitlab::GitalyClient::StorageSettings.new('path' => tmp_dir) }.with_indifferent_access end @@ -56,7 +56,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do context 'storage points to not existing folder' do let(:storages_paths) do { - default: { path: 'tmp/this/path/doesnt/exist' } + default: Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/this/path/doesnt/exist') }.with_indifferent_access end @@ -102,7 +102,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do context 'storage points to not existing folder' do let(:storages_paths) do { - default: { path: 'tmp/this/path/doesnt/exist' } + default: Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/this/path/doesnt/exist') }.with_indifferent_access end diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb new file mode 100644 index 00000000000..b0bc081a3c8 --- /dev/null +++ b/spec/lib/gitlab/http_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::HTTP do + describe 'allow_local_requests_from_hooks_and_services is' do + before do + WebMock.stub_request(:get, /.*/).to_return(status: 200, body: 'Success') + end + + context 'disabled' do + before do + allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(false) + end + + it 'deny requests to localhost' do + expect { described_class.get('http://localhost:3003') }.to raise_error(URI::InvalidURIError) + end + + it 'deny requests to private network' do + expect { described_class.get('http://192.168.1.2:3003') }.to raise_error(URI::InvalidURIError) + end + + context 'if allow_local_requests set to true' do + it 'override the global value and allow requests to localhost or private network' do + expect { described_class.get('http://localhost:3003', allow_local_requests: true) }.not_to raise_error + end + end + end + + context 'enabled' do + before do + allow(Gitlab::CurrentSettings).to receive(:allow_local_requests_from_hooks_and_services?).and_return(true) + end + + it 'allow requests to localhost' do + expect { described_class.get('http://localhost:3003') }.not_to raise_error + end + + it 'allow requests to private network' do + expect { described_class.get('http://192.168.1.2:3003') }.not_to raise_error + end + + context 'if allow_local_requests set to false' do + it 'override the global value and ban requests to localhost or private network' do + expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(URI::InvalidURIError) + end + end + end + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index bece82e531a..a204a8f1ffe 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -279,6 +279,7 @@ project: - lfs_file_locks - project_badges - source_of_merge_requests +- internal_ids award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0b938892da5..0716852f57f 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -265,7 +265,9 @@ CommitStatus: - target_url - description - artifacts_file +- artifacts_file_store - artifacts_metadata +- artifacts_metadata_store - erased_by_id - erased_at - artifacts_expire_at @@ -458,6 +460,7 @@ Project: - merge_requests_ff_only_enabled - merge_requests_rebase_enabled - jobs_cache_index +- pages_https_only Author: - name ProjectFeature: diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb new file mode 100644 index 00000000000..d808b4d49e0 --- /dev/null +++ b/spec/lib/gitlab/omniauth_initializer_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Gitlab::OmniauthInitializer do + let(:devise_config) { class_double(Devise) } + + subject { described_class.new(devise_config) } + + describe '#execute' do + it 'configures providers from array' do + generic_config = { 'name' => 'generic' } + + expect(devise_config).to receive(:omniauth).with(:generic) + + subject.execute([generic_config]) + end + + it 'allows "args" array for app_id and app_secret' do + legacy_config = { 'name' => 'legacy', 'args' => %w(123 abc) } + + expect(devise_config).to receive(:omniauth).with(:legacy, '123', 'abc') + + subject.execute([legacy_config]) + end + + it 'passes app_id and app_secret as additional arguments' do + twitter_config = { 'name' => 'twitter', 'app_id' => '123', 'app_secret' => 'abc' } + + expect(devise_config).to receive(:omniauth).with(:twitter, '123', 'abc') + + subject.execute([twitter_config]) + end + + it 'passes "args" hash as symbolized hash argument' do + hash_config = { 'name' => 'hash', 'args' => { 'custom' => 'format' } } + + expect(devise_config).to receive(:omniauth).with(:hash, custom: 'format') + + subject.execute([hash_config]) + end + + it 'configures fail_with_empty_uid for shibboleth' do + shibboleth_config = { 'name' => 'shibboleth', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:shibboleth, fail_with_empty_uid: true) + + subject.execute([shibboleth_config]) + end + + it 'configures remote_sign_out_handler proc for authentiq' do + authentiq_config = { 'name' => 'authentiq', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:authentiq, remote_sign_out_handler: an_instance_of(Proc)) + + subject.execute([authentiq_config]) + end + + it 'configures on_single_sign_out proc for cas3' do + cas3_config = { 'name' => 'cas3', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:cas3, on_single_sign_out: an_instance_of(Proc)) + + subject.execute([cas3_config]) + end + end +end diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb index 3d5b56cd5b8..548eb28fe4d 100644 --- a/spec/lib/gitlab/profiler_spec.rb +++ b/spec/lib/gitlab/profiler_spec.rb @@ -110,8 +110,8 @@ describe Gitlab::Profiler do custom_logger.debug('User Load (1.3ms)') custom_logger.debug('Project Load (10.4ms)') - expect(custom_logger.load_times_by_model).to eq('User' => 2.5, - 'Project' => 10.4) + expect(custom_logger.load_times_by_model).to eq('User' => [1.2, 1.3], + 'Project' => [10.4]) end it 'logs the backtrace, ignoring lines as appropriate' do @@ -164,4 +164,24 @@ describe Gitlab::Profiler do end end end + + describe '.log_load_times_by_model' do + it 'logs the model, query count, and time by slowest first' do + expect(null_logger).to receive(:load_times_by_model).and_return( + 'User' => [1.2, 1.3], + 'Project' => [10.4] + ) + + expect(null_logger).to receive(:info).with('Project total (1): 10.4ms') + expect(null_logger).to receive(:info).with('User total (2): 2.5ms') + + described_class.log_load_times_by_model(null_logger) + end + + it 'does nothing when called with a logger that does not have load times' do + expect(null_logger).not_to receive(:info) + + expect(described_class.log_load_times_by_model(null_logger)).to be_nil + end + end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 57905a74e92..8351b967133 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -83,19 +83,19 @@ describe Gitlab::ProjectSearchResults do end context 'when the matching filename contains a colon' do - let(:search_result) { "\nmaster:testdata/project::function1.yaml\x001\x00---\n" } + let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } it 'returns a valid FoundBlob' do expect(subject.filename).to eq('testdata/project::function1.yaml') expect(subject.basename).to eq('testdata/project::function1') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq('---') + expect(subject.data).to eq("---\n") end end context 'when the matching content contains a number surrounded by colons' do - let(:search_result) { "\nmaster:testdata/foo.txt\x001\x00blah:9:blah" } + let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } it 'returns a valid FoundBlob' do expect(subject.filename).to eq('testdata/foo.txt') @@ -106,6 +106,18 @@ describe Gitlab::ProjectSearchResults do end end + context 'when the search result ends with an empty line' do + let(:results) { project.repository.search_files_by_content('Role models', 'master') } + + it 'returns a valid FoundBlob that ends with an empty line' do + expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') + expect(subject.basename).to eq('files/markdown/ruby-style-guide') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n") + end + end + context 'when the search returns non-ASCII data' do context 'with UTF-8' do let(:results) { project.repository.search_files_by_content('файл', 'master') } @@ -115,7 +127,7 @@ describe Gitlab::ProjectSearchResults do expect(subject.basename).to eq('encoding/russian') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq('Хороший файл') + expect(subject.data).to eq("Хороший файл\n") end end @@ -139,7 +151,7 @@ describe Gitlab::ProjectSearchResults do expect(subject.basename).to eq('encoding/iso8859') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq("Äü\n\nfoo") + expect(subject.data).to eq("Äü\n\nfoo\n") end end end diff --git a/spec/lib/gitlab/repo_path_spec.rb b/spec/lib/gitlab/repo_path_spec.rb index b67bcc77bd4..f030f371372 100644 --- a/spec/lib/gitlab/repo_path_spec.rb +++ b/spec/lib/gitlab/repo_path_spec.rb @@ -48,8 +48,8 @@ describe ::Gitlab::RepoPath do describe '.strip_storage_path' do before do allow(Gitlab.config.repositories).to receive(:storages).and_return({ - 'storage1' => { 'path' => '/foo' }, - 'storage2' => { 'path' => '/bar' } + 'storage1' => Gitlab::GitalyClient::StorageSettings.new('path' => '/foo'), + 'storage2' => Gitlab::GitalyClient::StorageSettings.new('path' => '/bar') }) end diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 14b59c5e945..ea5ce58e34b 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -405,7 +405,7 @@ describe Gitlab::Shell do describe '#create_repository' do shared_examples '#create_repository' do let(:repository_storage) { 'default' } - let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } let(:repo_name) { 'project/path' } let(:created_path) { File.join(repository_storage_path, repo_name + '.git') } @@ -679,7 +679,7 @@ describe Gitlab::Shell do describe 'namespace actions' do subject { described_class.new } - let(:storage_path) { Gitlab.config.repositories.storages.default.path } + let(:storage_path) { Gitlab.config.repositories.storages.default.legacy_disk_path } describe '#add_namespace' do it 'creates a namespace' do diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb index d9b3c2350b1..2d35b026485 100644 --- a/spec/lib/gitlab/url_blocker_spec.rb +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' describe Gitlab::UrlBlocker do describe '#blocked_url?' do + let(:valid_ports) { Project::VALID_IMPORT_PORTS } + it 'allows imports from configured web host and port' do import_url = "http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git" expect(described_class.blocked_url?(import_url)).to be false @@ -17,7 +19,7 @@ describe Gitlab::UrlBlocker do end it 'returns true for bad port' do - expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git')).to be true + expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git', valid_ports: valid_ports)).to be true end it 'returns true for alternative version of 127.0.0.1 (0177.1)' do @@ -71,6 +73,47 @@ describe Gitlab::UrlBlocker do it 'returns false for legitimate URL' do expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git')).to be false end + + context 'when allow_private_networks is' do + let(:private_networks) { ['192.168.1.2', '10.0.0.2', '172.16.0.2'] } + let(:fake_domain) { 'www.fakedomain.fake' } + + context 'true (default)' do + it 'does not block urls from private networks' do + private_networks.each do |ip| + stub_domain_resolv(fake_domain, ip) + + expect(described_class).not_to be_blocked_url("http://#{fake_domain}") + + unstub_domain_resolv + + expect(described_class).not_to be_blocked_url("http://#{ip}") + end + end + end + + context 'false' do + it 'blocks urls from private networks' do + private_networks.each do |ip| + stub_domain_resolv(fake_domain, ip) + + expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_private_networks: false) + + unstub_domain_resolv + + expect(described_class).to be_blocked_url("http://#{ip}", allow_private_networks: false) + end + end + end + + def stub_domain_resolv(domain, ip) + allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([double(ip_address: ip, ipv4_private?: true)]) + end + + def unstub_domain_resolv + allow(Addrinfo).to receive(:getaddrinfo).and_call_original + end + end end # Resolv does not support resolving UTF-8 domain names diff --git a/spec/lib/gitlab/verify/lfs_objects_spec.rb b/spec/lib/gitlab/verify/lfs_objects_spec.rb index 64f3a9660e0..0f890e2c7ce 100644 --- a/spec/lib/gitlab/verify/lfs_objects_spec.rb +++ b/spec/lib/gitlab/verify/lfs_objects_spec.rb @@ -31,5 +31,21 @@ describe Gitlab::Verify::LfsObjects do expect(failures.keys).to contain_exactly(lfs_object) expect(failure.to_s).to include('Checksum mismatch') end + + context 'with remote files' do + before do + stub_lfs_object_storage + end + + it 'skips LFS objects in object storage' do + local_failure = create(:lfs_object) + create(:lfs_object, :object_storage) + + failures = {} + described_class.new(batch_size: 10).run_batches { |_, failed| failures.merge!(failed) } + + expect(failures.keys).to contain_exactly(local_failure) + end + end end end diff --git a/spec/lib/gitlab/verify/uploads_spec.rb b/spec/lib/gitlab/verify/uploads_spec.rb index 6146ce61226..85768308edc 100644 --- a/spec/lib/gitlab/verify/uploads_spec.rb +++ b/spec/lib/gitlab/verify/uploads_spec.rb @@ -40,5 +40,21 @@ describe Gitlab::Verify::Uploads do expect(failures.keys).to contain_exactly(upload) expect(failure.to_s).to include('Checksum missing') end + + context 'with remote files' do + before do + stub_uploads_object_storage(AvatarUploader) + end + + it 'skips uploads in object storage' do + local_failure = create(:upload) + create(:upload, :object_storage) + + failures = {} + described_class.new(batch_size: 10).run_batches { |_, failed| failures.merge!(failed) } + + expect(failures.keys).to contain_exactly(local_failure) + end + end end end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index 37a0bf1ad36..d67bfb37be7 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -55,7 +55,7 @@ describe Gitlab::Workhorse do end end - context 'when Gitaly workhorse_archive feature is disabled', :skip_gitaly_mock do + context 'when Gitaly workhorse_archive feature is disabled', :disable_gitaly do it 'sets the header correctly' do key, command, params = decode_workhorse_header(subject) @@ -100,7 +100,7 @@ describe Gitlab::Workhorse do end end - context 'when Gitaly workhorse_send_git_patch feature is disabled', :skip_gitaly_mock do + context 'when Gitaly workhorse_send_git_patch feature is disabled', :disable_gitaly do it 'sets the header correctly' do key, command, params = decode_workhorse_header(subject) @@ -173,7 +173,7 @@ describe Gitlab::Workhorse do end end - context 'when Gitaly workhorse_send_git_diff feature is disabled', :skip_gitaly_mock do + context 'when Gitaly workhorse_send_git_diff feature is disabled', :disable_gitaly do it 'sets the header correctly' do key, command, params = decode_workhorse_header(subject) @@ -275,14 +275,12 @@ describe Gitlab::Workhorse do describe '.git_http_ok' do let(:user) { create(:user) } - let(:repo_path) { repository.path_to_repo } let(:action) { 'info_refs' } let(:params) do { GL_ID: "user-#{user.id}", GL_USERNAME: user.username, GL_REPOSITORY: "project-#{project.id}", - RepoPath: repo_path, ShowAllRefs: false } end @@ -297,7 +295,6 @@ describe Gitlab::Workhorse do GL_ID: "user-#{user.id}", GL_USERNAME: user.username, GL_REPOSITORY: "wiki-#{project.id}", - RepoPath: repo_path, ShowAllRefs: false } end @@ -455,7 +452,7 @@ describe Gitlab::Workhorse do end end - context 'when Gitaly workhorse_raw_show feature is disabled', :skip_gitaly_mock do + context 'when Gitaly workhorse_raw_show feature is disabled', :disable_gitaly do it 'sets the header correctly' do key, command, params = decode_workhorse_header(subject) diff --git a/spec/lib/mattermost/command_spec.rb b/spec/lib/mattermost/command_spec.rb index 369e7b181b9..8ba15ae0f38 100644 --- a/spec/lib/mattermost/command_spec.rb +++ b/spec/lib/mattermost/command_spec.rb @@ -4,10 +4,11 @@ describe Mattermost::Command do let(:params) { { 'token' => 'token', team_id: 'abc' } } before do - Mattermost::Session.base_uri('http://mattermost.example.com') + session = Mattermost::Session.new(nil) + session.base_uri = 'http://mattermost.example.com' allow_any_instance_of(Mattermost::Client).to receive(:with_session) - .and_yield(Mattermost::Session.new(nil)) + .and_yield(session) end describe '#create' do diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index 3db19d06305..c855643c4d8 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -15,7 +15,7 @@ describe Mattermost::Session, type: :request do it { is_expected.to respond_to(:strategy) } before do - described_class.base_uri(mattermost_url) + subject.base_uri = mattermost_url end describe '#with session' do diff --git a/spec/lib/mattermost/team_spec.rb b/spec/lib/mattermost/team_spec.rb index 3c8206031cf..2cfa6802612 100644 --- a/spec/lib/mattermost/team_spec.rb +++ b/spec/lib/mattermost/team_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' describe Mattermost::Team do before do - Mattermost::Session.base_uri('http://mattermost.example.com') + session = Mattermost::Session.new(nil) + session.base_uri = 'http://mattermost.example.com' allow_any_instance_of(Mattermost::Client).to receive(:with_session) - .and_yield(Mattermost::Session.new(nil)) + .and_yield(session) end describe '#all' do diff --git a/spec/migrations/remove_dot_git_from_usernames_spec.rb b/spec/migrations/remove_dot_git_from_usernames_spec.rb index 129374cb38c..3a88a66a476 100644 --- a/spec/migrations/remove_dot_git_from_usernames_spec.rb +++ b/spec/migrations/remove_dot_git_from_usernames_spec.rb @@ -29,7 +29,9 @@ describe RemoveDotGitFromUsernames do update_namespace(user, 'test.git') update_namespace(user2, 'test_git') - storages = { 'default' => 'tmp/tests/custom_repositories' } + default_hash = Gitlab.config.repositories.storages.default.to_h + default_hash['path'] = 'tmp/tests/custom_repositories' + storages = { 'default' => Gitlab::GitalyClient::StorageSettings.new(default_hash) } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) allow(migration).to receive(:route_exists?).with('test_git').and_return(true) diff --git a/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb b/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb new file mode 100644 index 00000000000..441c4295a40 --- /dev/null +++ b/spec/migrations/remove_empty_extern_uid_auth0_identities_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180220150310_remove_empty_extern_uid_auth0_identities.rb') + +describe RemoveEmptyExternUidAuth0Identities, :migration do + let(:identities) { table(:identities) } + + before do + identities.create(provider: 'auth0', extern_uid: '') + identities.create(provider: 'auth0', extern_uid: 'valid') + identities.create(provider: 'github', extern_uid: '') + + migrate! + end + + it 'leaves the correct auth0 identity' do + expect(identities.where(provider: 'auth0').pluck(:extern_uid)).to eq(['valid']) + end + + it 'leaves the correct github identity' do + expect(identities.where(provider: 'github').count).to eq(1) + end +end diff --git a/spec/migrations/remove_empty_fork_networks_spec.rb b/spec/migrations/remove_empty_fork_networks_spec.rb index 7f7ce91378b..f6d030ab25c 100644 --- a/spec/migrations/remove_empty_fork_networks_spec.rb +++ b/spec/migrations/remove_empty_fork_networks_spec.rb @@ -19,6 +19,10 @@ describe RemoveEmptyForkNetworks, :migration do deleted_project.destroy! end + after do + Upload.reset_column_information + end + it 'deletes only the fork network without members' do expect(fork_networks.count).to eq(2) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 9da3de7a828..7d935cf8d76 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -198,6 +198,16 @@ describe Ci::Build do end context 'when legacy artifacts are used' do + let(:build) { create(:ci_build, :legacy_artifacts) } + + subject { build.artifacts? } + + context 'is expired' do + let(:build) { create(:ci_build, :legacy_artifacts, :expired) } + + it { is_expected.to be_falsy } + end + context 'artifacts archive does not exist' do let(:build) { create(:ci_build) } @@ -208,13 +218,25 @@ describe Ci::Build do let(:build) { create(:ci_build, :legacy_artifacts) } it { is_expected.to be_truthy } + end + end + end - context 'is expired' do - let(:build) { create(:ci_build, :legacy_artifacts, :expired) } + describe '#browsable_artifacts?' do + subject { build.browsable_artifacts? } - it { is_expected.to be_falsy } - end + context 'artifacts metadata does not exist' do + before do + build.update_attributes(legacy_artifacts_metadata: nil) end + + it { is_expected.to be_falsy } + end + + context 'artifacts metadata does exists' do + let(:build) { create(:ci_build, :artifacts) } + + it { is_expected.to be_truthy } end end @@ -2101,6 +2123,35 @@ describe Ci::Build do subject.drop! end + + context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do + let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) } + + before do + allow_any_instance_of(Ci::RetryBuildService) + .to receive(:execute) + .with(subject) + .and_raise(Gitlab::Access::AccessDeniedError) + allow(Rails.logger).to receive(:error) + end + + it 'handles raised exception' do + expect { subject.drop! }.not_to raise_exception(Gitlab::Access::AccessDeniedError) + end + + it 'logs the error' do + subject.drop! + + expect(Rails.logger) + .to have_received(:error) + .with(a_string_matching("Unable to auto-retry job #{subject.id}")) + end + + it 'fails the job' do + subject.drop! + expect(subject.failed?).to be_truthy + end + end end context 'when build is not configured to be retried' do diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index a2bd36537e6..1aa28434879 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -15,6 +15,50 @@ describe Ci::JobArtifact do it { is_expected.to delegate_method(:open).to(:file) } it { is_expected.to delegate_method(:exists?).to(:file) } + describe 'callbacks' do + subject { create(:ci_job_artifact, :archive) } + + describe '#schedule_background_upload' do + context 'when object storage is disabled' do + before do + stub_artifacts_object_storage(enabled: false) + end + + it 'does not schedule the migration' do + expect(ObjectStorageUploadWorker).not_to receive(:perform_async) + + subject + end + end + + context 'when object storage is enabled' do + context 'when background upload is enabled' do + before do + stub_artifacts_object_storage(background_upload: true) + end + + it 'schedules the model for migration' do + expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with('JobArtifactUploader', described_class.name, :file, kind_of(Numeric)) + + subject + end + end + + context 'when background upload is disabled' do + before do + stub_artifacts_object_storage(background_upload: false) + end + + it 'schedules the model for migration' do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + subject + end + end + end + end + end + describe '#set_size' do it 'sets the size' do expect(artifact.size).to eq(106365) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 4635f8cfe9d..92f00cfbc19 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -177,6 +177,24 @@ describe Ci::Pipeline, :mailer do end end + describe '#protected_ref?' do + it 'delegates method to project' do + expect(pipeline).not_to be_protected_ref + end + end + + describe '#legacy_trigger' do + let(:trigger_request) { create(:ci_trigger_request) } + + before do + pipeline.trigger_requests << trigger_request + end + + it 'returns first trigger request' do + expect(pipeline.legacy_trigger).to eq trigger_request + end + end + describe '#auto_canceled?' do subject { pipeline.auto_canceled? } @@ -215,142 +233,257 @@ describe Ci::Pipeline, :mailer do end describe 'pipeline stages' do - before do - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'linux', - stage_idx: 0, - status: 'success') - - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'mac', - stage_idx: 0, - status: 'failed') - - create(:commit_status, pipeline: pipeline, - stage: 'deploy', - name: 'staging', - stage_idx: 2, - status: 'running') - - create(:commit_status, pipeline: pipeline, - stage: 'test', - name: 'rspec', - stage_idx: 1, - status: 'success') - end - describe '#stage_seeds' do - let(:pipeline) do - build(:ci_pipeline, config: { rspec: { script: 'rake' } }) - end + let(:pipeline) { build(:ci_pipeline, config: config) } + let(:config) { { rspec: { script: 'rake' } } } it 'returns preseeded stage seeds object' do - expect(pipeline.stage_seeds).to all(be_a Gitlab::Ci::Stage::Seed) + expect(pipeline.stage_seeds) + .to all(be_a Gitlab::Ci::Pipeline::Seed::Base) expect(pipeline.stage_seeds.count).to eq 1 end - end - describe '#seeds_size' do - let(:pipeline) { build(:ci_pipeline_with_one_job) } + context 'when no refs policy is specified' do + let(:config) do + { production: { stage: 'deploy', script: 'cap prod' }, + rspec: { stage: 'test', script: 'rspec' }, + spinach: { stage: 'test', script: 'spinach' } } + end - it 'returns number of jobs in stage seeds' do - expect(pipeline.seeds_size).to eq 1 + it 'correctly fabricates a stage seeds object' do + seeds = pipeline.stage_seeds + + expect(seeds.size).to eq 2 + expect(seeds.first.attributes[:name]).to eq 'test' + expect(seeds.second.attributes[:name]).to eq 'deploy' + expect(seeds.dig(0, 0, :name)).to eq 'rspec' + expect(seeds.dig(0, 1, :name)).to eq 'spinach' + expect(seeds.dig(1, 0, :name)).to eq 'production' + end end - end - describe '#legacy_stages' do - subject { pipeline.legacy_stages } + context 'when refs policy is specified' do + let(:pipeline) do + build(:ci_pipeline, ref: 'feature', tag: true, config: config) + end - context 'stages list' do - it 'returns ordered list of stages' do - expect(subject.map(&:name)).to eq(%w[build test deploy]) + let(:config) do + { production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, + spinach: { stage: 'test', script: 'spinach', only: ['tags'] } } + end + + it 'returns stage seeds only assigned to master to master' do + seeds = pipeline.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.first.attributes[:name]).to eq 'test' + expect(seeds.dig(0, 0, :name)).to eq 'spinach' end end - context 'stages with statuses' do - let(:statuses) do - subject.map { |stage| [stage.name, stage.status] } + context 'when source policy is specified' do + let(:pipeline) { build(:ci_pipeline, source: :schedule, config: config) } + + let(:config) do + { production: { stage: 'deploy', script: 'cap prod', only: ['triggers'] }, + spinach: { stage: 'test', script: 'spinach', only: ['schedules'] } } end - it 'returns list of stages with correct statuses' do - expect(statuses).to eq([%w(build failed), - %w(test success), - %w(deploy running)]) + it 'returns stage seeds only assigned to schedules' do + seeds = pipeline.stage_seeds + + expect(seeds.size).to eq 1 + expect(seeds.first.attributes[:name]).to eq 'test' + expect(seeds.dig(0, 0, :name)).to eq 'spinach' end + end - context 'when commit status is retried' do - before do - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'mac', - stage_idx: 0, - status: 'success') + context 'when kubernetes policy is specified' do + let(:config) do + { + spinach: { stage: 'test', script: 'spinach' }, + production: { + stage: 'deploy', + script: 'cap', + only: { kubernetes: 'active' } + } + } + end + + context 'when kubernetes is active' do + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'returns seeds for kubernetes dependent job' do + seeds = pipeline.stage_seeds - pipeline.process! + expect(seeds.size).to eq 2 + expect(seeds.dig(0, 0, :name)).to eq 'spinach' + expect(seeds.dig(1, 0, :name)).to eq 'production' + end end - it 'ignores the previous state' do - expect(statuses).to eq([%w(build success), - %w(test success), - %w(deploy running)]) + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + let(:pipeline) { build(:ci_pipeline, project: project, config: config) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end - end - end - context 'when there is a stage with warnings' do - before do - create(:commit_status, pipeline: pipeline, - stage: 'deploy', - name: 'prod:2', - stage_idx: 2, - status: 'failed', - allow_failure: true) + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + let(:pipeline) { build(:ci_pipeline, project: project, config: config) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end end - it 'populates stage with correct number of warnings' do - deploy_stage = pipeline.legacy_stages.third + context 'when kubernetes is not active' do + it 'does not return seeds for kubernetes dependent job' do + seeds = pipeline.stage_seeds - expect(deploy_stage).not_to receive(:statuses) - expect(deploy_stage).to have_warnings + expect(seeds.size).to eq 1 + expect(seeds.dig(0, 0, :name)).to eq 'spinach' + end end end end - describe '#stages_count' do - it 'returns a valid number of stages' do - expect(pipeline.stages_count).to eq(3) - end - end + describe '#seeds_size' do + context 'when refs policy is specified' do + let(:config) do + { production: { stage: 'deploy', script: 'cap prod', only: ['master'] }, + spinach: { stage: 'test', script: 'spinach', only: ['tags'] } } + end - describe '#stages_names' do - it 'returns a valid names of stages' do - expect(pipeline.stages_names).to eq(%w(build test deploy)) + let(:pipeline) do + build(:ci_pipeline, ref: 'feature', tag: true, config: config) + end + + it 'returns real seeds size' do + expect(pipeline.seeds_size).to eq 1 + end end end - end - - describe '#legacy_stage' do - subject { pipeline.legacy_stage('test') } - context 'with status in stage' do + describe 'legacy stages' do before do - create(:commit_status, pipeline: pipeline, stage: 'test') + create(:commit_status, pipeline: pipeline, + stage: 'build', + name: 'linux', + stage_idx: 0, + status: 'success') + + create(:commit_status, pipeline: pipeline, + stage: 'build', + name: 'mac', + stage_idx: 0, + status: 'failed') + + create(:commit_status, pipeline: pipeline, + stage: 'deploy', + name: 'staging', + stage_idx: 2, + status: 'running') + + create(:commit_status, pipeline: pipeline, + stage: 'test', + name: 'rspec', + stage_idx: 1, + status: 'success') + end + + describe '#legacy_stages' do + subject { pipeline.legacy_stages } + + context 'stages list' do + it 'returns ordered list of stages' do + expect(subject.map(&:name)).to eq(%w[build test deploy]) + end + end + + context 'stages with statuses' do + let(:statuses) do + subject.map { |stage| [stage.name, stage.status] } + end + + it 'returns list of stages with correct statuses' do + expect(statuses).to eq([%w(build failed), + %w(test success), + %w(deploy running)]) + end + + context 'when commit status is retried' do + before do + create(:commit_status, pipeline: pipeline, + stage: 'build', + name: 'mac', + stage_idx: 0, + status: 'success') + + pipeline.process! + end + + it 'ignores the previous state' do + expect(statuses).to eq([%w(build success), + %w(test success), + %w(deploy running)]) + end + end + end + + context 'when there is a stage with warnings' do + before do + create(:commit_status, pipeline: pipeline, + stage: 'deploy', + name: 'prod:2', + stage_idx: 2, + status: 'failed', + allow_failure: true) + end + + it 'populates stage with correct number of warnings' do + deploy_stage = pipeline.legacy_stages.third + + expect(deploy_stage).not_to receive(:statuses) + expect(deploy_stage).to have_warnings + end + end + end + + describe '#stages_count' do + it 'returns a valid number of stages' do + expect(pipeline.stages_count).to eq(3) + end end - it { expect(subject).to be_a Ci::LegacyStage } - it { expect(subject.name).to eq 'test' } - it { expect(subject.statuses).not_to be_empty } + describe '#stages_names' do + it 'returns a valid names of stages' do + expect(pipeline.stages_names).to eq(%w(build test deploy)) + end + end end - context 'without status in stage' do - before do - create(:commit_status, pipeline: pipeline, stage: 'build') + describe '#legacy_stage' do + subject { pipeline.legacy_stage('test') } + + context 'with status in stage' do + before do + create(:commit_status, pipeline: pipeline, stage: 'test') + end + + it { expect(subject).to be_a Ci::LegacyStage } + it { expect(subject.name).to eq 'test' } + it { expect(subject.statuses).not_to be_empty } end - it 'return stage object' do - is_expected.to be_nil + context 'without status in stage' do + before do + create(:commit_status, pipeline: pipeline, stage: 'build') + end + + it 'return stage object' do + is_expected.to be_nil + end end end end @@ -589,20 +722,6 @@ describe Ci::Pipeline, :mailer do end end - describe '#has_stage_seeds?' do - context 'when pipeline has stage seeds' do - subject { build(:ci_pipeline_with_one_job) } - - it { is_expected.to have_stage_seeds } - end - - context 'when pipeline does not have stage seeds' do - subject { create(:ci_pipeline_without_jobs) } - - it { is_expected.not_to have_stage_seeds } - end - end - describe '#has_warnings?' do subject { pipeline.has_warnings? } diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 4b217df2e8f..f8874d14e3f 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -34,7 +34,7 @@ describe Issuable do subject { build(:issue) } before do - allow(subject).to receive(:set_iid).and_return(false) + allow(InternalId).to receive(:generate_next).and_return(nil) end it { is_expected.to validate_presence_of(:project) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index abfc0896a41..d620943693c 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -240,7 +240,7 @@ describe Group do it "is false if avatar is html page" do group.update_attribute(:avatar, 'uploads/avatar.html') - expect(group.avatar_type).to eq(["only images allowed"]) + expect(group.avatar_type).to eq(["file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff"]) end end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb new file mode 100644 index 00000000000..581fd0293cc --- /dev/null +++ b/spec/models/internal_id_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +describe InternalId do + let(:project) { create(:project) } + let(:usage) { :issues } + let(:issue) { build(:issue, project: project) } + let(:scope) { { project: project } } + let(:init) { ->(s) { s.project.issues.size } } + + context 'validations' do + it { is_expected.to validate_presence_of(:usage) } + end + + describe '.generate_next' do + subject { described_class.generate_next(issue, scope, usage, init) } + + context 'in the absence of a record' do + it 'creates a record if not yet present' do + expect { subject }.to change { described_class.count }.from(0).to(1) + end + + it 'stores record attributes' do + subject + + described_class.first.tap do |record| + expect(record.project).to eq(project) + expect(record.usage).to eq(usage.to_s) + end + end + + context 'with existing issues' do + before do + rand(1..10).times { create(:issue, project: project) } + described_class.delete_all + end + + it 'calculates last_value values automatically' do + expect(subject).to eq(project.issues.size + 1) + end + end + + context 'with concurrent inserts on table' do + it 'looks up the record if it was created concurrently' do + args = { **scope, usage: described_class.usages[usage.to_s] } + record = double + expect(described_class).to receive(:find_by).with(args).and_return(nil) # first call, record not present + expect(described_class).to receive(:find_by).with(args).and_return(record) # second call, record was created by another process + expect(described_class).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique, 'record not unique') + expect(record).to receive(:increment_and_save!) + + subject + end + end + end + + it 'generates a strictly monotone, gapless sequence' do + seq = (0..rand(100)).map do + described_class.generate_next(issue, scope, usage, init) + end + normalized = seq.map { |i| i - seq.min } + + expect(normalized).to eq((0..seq.size - 1).to_a) + end + + context 'with an insufficient schema version' do + before do + described_class.reset_column_information + expect(ActiveRecord::Migrator).to receive(:current_version).and_return(InternalId::REQUIRED_SCHEMA_VERSION - 1) + end + + let(:init) { double('block') } + + it 'calculates next internal ids on the fly' do + val = rand(1..100) + + expect(init).to receive(:call).with(issue).and_return(val) + expect(subject).to eq(val + 1) + end + end + end + + describe '#increment_and_save!' do + let(:id) { create(:internal_id) } + subject { id.increment_and_save! } + + it 'returns incremented iid' do + value = id.last_value + + expect(subject).to eq(value + 1) + end + + it 'saves the record' do + subject + + expect(id.changed?).to be_falsey + end + + context 'with last_value=nil' do + let(:id) { build(:internal_id, last_value: nil) } + + it 'returns 1' do + expect(subject).to eq(1) + end + end + end +end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index feed7968f09..11154291368 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -9,11 +9,17 @@ describe Issue do describe 'modules' do subject { described_class } - it { is_expected.to include_module(InternalId) } it { is_expected.to include_module(Issuable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } it { is_expected.to include_module(Taskable) } + + it_behaves_like 'AtomicInternalId' do + let(:internal_id_attribute) { :iid } + let(:instance) { build(:issue) } + let(:scope_attrs) { { project: instance.project } } + let(:usage) { :issues } + end end subject { create(:issue) } diff --git a/spec/models/lfs_object_spec.rb b/spec/models/lfs_object_spec.rb new file mode 100644 index 00000000000..a182116d637 --- /dev/null +++ b/spec/models/lfs_object_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe LfsObject do + describe '#local_store?' do + it 'returns true when file_store is nil' do + subject.file_store = nil + + expect(subject.local_store?).to eq true + end + + it 'returns true when file_store is equal to LfsObjectUploader::Store::LOCAL' do + subject.file_store = LfsObjectUploader::Store::LOCAL + + expect(subject.local_store?).to eq true + end + + it 'returns false whe file_store is equal to LfsObjectUploader::Store::REMOTE' do + subject.file_store = LfsObjectUploader::Store::REMOTE + + expect(subject.local_store?).to eq false + end + end + + describe '#schedule_background_upload' do + before do + stub_lfs_setting(enabled: true) + end + + subject { create(:lfs_object, :with_file) } + + context 'when object storage is disabled' do + before do + stub_lfs_object_storage(enabled: false) + end + + it 'does not schedule the migration' do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + subject + end + end + + context 'when object storage is enabled' do + context 'when background upload is enabled' do + context 'when is licensed' do + before do + stub_lfs_object_storage(background_upload: true) + end + + it 'schedules the model for migration' do + expect(ObjectStorage::BackgroundMoveWorker) + .to receive(:perform_async) + .with('LfsObjectUploader', described_class.name, :file, kind_of(Numeric)) + .once + + subject + end + + it 'schedules the model for migration once' do + expect(ObjectStorage::BackgroundMoveWorker) + .to receive(:perform_async) + .with('LfsObjectUploader', described_class.name, :file, kind_of(Numeric)) + .once + + lfs_object = create(:lfs_object) + lfs_object.file = fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") + lfs_object.save! + end + end + end + + context 'when background upload is disabled' do + before do + stub_lfs_object_storage(background_upload: false) + end + + it 'schedules the model for migration' do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + subject + end + end + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 4e783acbd8b..f73f44ca0ad 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -17,7 +17,7 @@ describe MergeRequest do describe 'modules' do subject { described_class } - it { is_expected.to include_module(InternalId) } + it { is_expected.to include_module(NonatomicInternalId) } it { is_expected.to include_module(Issuable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } @@ -1961,6 +1961,17 @@ describe MergeRequest do expect(subject.merge_request_diff_for(merge_request_diff3.head_commit_sha)).to eq(merge_request_diff3) end end + + it 'runs a single query on the initial call, and none afterwards' do + expect { subject.merge_request_diff_for(merge_request_diff1.diff_refs) } + .not_to exceed_query_limit(1) + + expect { subject.merge_request_diff_for(merge_request_diff2.diff_refs) } + .not_to exceed_query_limit(0) + + expect { subject.merge_request_diff_for(merge_request_diff3.head_commit_sha) } + .not_to exceed_query_limit(0) + end end describe '#version_params_for' do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index ee142718f7e..62e95a622eb 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -305,7 +305,7 @@ describe Namespace do end describe '#rm_dir', 'callback' do - let(:repository_storage_path) { Gitlab.config.repositories.storages.default['path'] } + let(:repository_storage_path) { Gitlab.config.repositories.storages.default.legacy_disk_path } let(:path_in_dir) { File.join(repository_storage_path, namespace.full_path) } let(:deleted_path) { namespace.full_path.gsub(namespace.path, "#{namespace.full_path}+#{namespace.id}+deleted") } let(:deleted_path_in_dir) { File.join(repository_storage_path, deleted_path) } diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 95713d8b85b..4b85c5e8720 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -18,24 +18,63 @@ describe PagesDomain do it { is_expected.to validate_uniqueness_of(:domain).case_insensitive } end - { - 'my.domain.com' => true, - '123.456.789' => true, - '0x12345.com' => true, - '0123123' => true, - '_foo.com' => false, - 'reserved.com' => false, - 'a.reserved.com' => false, - nil => false - }.each do |value, validity| - context "domain #{value.inspect} validity" do - before do - allow(Settings.pages).to receive(:host).and_return('reserved.com') + describe "hostname" do + { + 'my.domain.com' => true, + '123.456.789' => true, + '0x12345.com' => true, + '0123123' => true, + '_foo.com' => false, + 'reserved.com' => false, + 'a.reserved.com' => false, + nil => false + }.each do |value, validity| + context "domain #{value.inspect} validity" do + before do + allow(Settings.pages).to receive(:host).and_return('reserved.com') + end + + let(:domain) { value } + + it { expect(pages_domain.valid?).to eq(validity) } + end + end + end + + describe "HTTPS-only" do + using RSpec::Parameterized::TableSyntax + + let(:domain) { 'my.domain.com' } + + let(:project) do + instance_double(Project, pages_https_only?: pages_https_only) + end + + let(:pages_domain) do + build(:pages_domain, certificate: certificate, key: key).tap do |pd| + allow(pd).to receive(:project).and_return(project) + pd.valid? end + end - let(:domain) { value } + where(:pages_https_only, :certificate, :key, :errors_on) do + attributes = attributes_for(:pages_domain) + cert, key = attributes.fetch_values(:certificate, :key) + + true | nil | nil | %i(certificate key) + true | cert | nil | %i(key) + true | nil | key | %i(certificate key) + true | cert | key | [] + false | nil | nil | [] + false | cert | nil | %i(key) + false | nil | key | %i(key) + false | cert | key | [] + end - it { expect(pages_domain.valid?).to eq(validity) } + with_them do + it "is adds the expected errors" do + expect(pages_domain.errors.keys).to eq errors_on + end end end end @@ -43,26 +82,26 @@ describe PagesDomain do describe 'validate certificate' do subject { domain } - context 'when only certificate is specified' do - let(:domain) { build(:pages_domain, :with_certificate) } + context 'with matching key' do + let(:domain) { build(:pages_domain) } - it { is_expected.not_to be_valid } + it { is_expected.to be_valid } end - context 'when only key is specified' do - let(:domain) { build(:pages_domain, :with_key) } + context 'when no certificate is specified' do + let(:domain) { build(:pages_domain, :without_certificate) } it { is_expected.not_to be_valid } end - context 'with matching key' do - let(:domain) { build(:pages_domain, :with_certificate, :with_key) } + context 'when no key is specified' do + let(:domain) { build(:pages_domain, :without_key) } - it { is_expected.to be_valid } + it { is_expected.not_to be_valid } end context 'for not matching key' do - let(:domain) { build(:pages_domain, :with_missing_chain, :with_key) } + let(:domain) { build(:pages_domain, :with_missing_chain) } it { is_expected.not_to be_valid } end @@ -103,30 +142,26 @@ describe PagesDomain do describe '#url' do subject { domain.url } - context 'without the certificate' do - let(:domain) { build(:pages_domain, certificate: '') } + let(:domain) { build(:pages_domain) } - it { is_expected.to eq("http://#{domain.domain}") } - end + it { is_expected.to eq("https://#{domain.domain}") } - context 'with a certificate' do - let(:domain) { build(:pages_domain, :with_certificate) } + context 'without the certificate' do + let(:domain) { build(:pages_domain, :without_certificate) } - it { is_expected.to eq("https://#{domain.domain}") } + it { is_expected.to eq("http://#{domain.domain}") } end end describe '#has_matching_key?' do subject { domain.has_matching_key? } - context 'for matching key' do - let(:domain) { build(:pages_domain, :with_certificate, :with_key) } + let(:domain) { build(:pages_domain) } - it { is_expected.to be_truthy } - end + it { is_expected.to be_truthy } context 'for invalid key' do - let(:domain) { build(:pages_domain, :with_missing_chain, :with_key) } + let(:domain) { build(:pages_domain, :with_missing_chain) } it { is_expected.to be_falsey } end @@ -136,7 +171,7 @@ describe PagesDomain do subject { domain.has_intermediates? } context 'for self signed' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } it { is_expected.to be_truthy } end @@ -162,7 +197,7 @@ describe PagesDomain do subject { domain.expired? } context 'for valid' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } it { is_expected.to be_falsey } end @@ -175,7 +210,7 @@ describe PagesDomain do end describe '#subject' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } subject { domain.subject } @@ -183,7 +218,7 @@ describe PagesDomain do end describe '#certificate_text' do - let(:domain) { build(:pages_domain, :with_certificate) } + let(:domain) { build(:pages_domain) } subject { domain.certificate_text } @@ -191,6 +226,18 @@ describe PagesDomain do it { is_expected.not_to be_empty } end + describe "#https?" do + context "when a certificate is present" do + subject { build(:pages_domain) } + it { is_expected.to be_https } + end + + context "when no certificate is present" do + subject { build(:pages_domain, :without_certificate) } + it { is_expected.not_to be_https } + end + end + describe '#update_daemon' do it 'runs when the domain is created' do domain = build(:pages_domain) @@ -267,29 +314,30 @@ describe PagesDomain do end context 'TLS configuration' do - set(:domain_with_tls) { create(:pages_domain, :with_key, :with_certificate) } + set(:domain_without_tls) { create(:pages_domain, :without_certificate, :without_key) } + set(:domain) { create(:pages_domain) } - let(:cert1) { domain_with_tls.certificate } + let(:cert1) { domain.certificate } let(:cert2) { cert1 + ' ' } - let(:key1) { domain_with_tls.key } + let(:key1) { domain.key } let(:key2) { key1 + ' ' } it 'updates when added' do - expect(domain).to receive(:update_daemon) + expect(domain_without_tls).to receive(:update_daemon) - domain.update!(key: key1, certificate: cert1) + domain_without_tls.update!(key: key1, certificate: cert1) end it 'updates when changed' do - expect(domain_with_tls).to receive(:update_daemon) + expect(domain).to receive(:update_daemon) - domain_with_tls.update!(key: key2, certificate: cert2) + domain.update!(key: key2, certificate: cert2) end it 'updates when removed' do - expect(domain_with_tls).to receive(:update_daemon) + expect(domain).to receive(:update_daemon) - domain_with_tls.update!(key: nil, certificate: nil) + domain.update!(key: nil, certificate: nil) end end end diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb index a5bdf9a9337..05d33cd3874 100644 --- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb +++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb @@ -9,10 +9,11 @@ describe MattermostSlashCommandsService do let(:user) { create(:user) } before do - Mattermost::Session.base_uri("http://mattermost.example.com") + session = Mattermost::Session.new(nil) + session.base_uri = 'http://mattermost.example.com' allow_any_instance_of(Mattermost::Client).to receive(:with_session) - .and_yield(Mattermost::Session.new(nil)) + .and_yield(session) end describe '#configure' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4cf8d861595..1a7a6e035ea 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -922,7 +922,7 @@ describe Project do it 'is false if avatar is html page' do project.update_attribute(:avatar, 'uploads/avatar.html') - expect(project.avatar_type).to eq(['only images allowed']) + expect(project.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff']) end end @@ -1101,8 +1101,8 @@ describe Project do before do storages = { - 'default' => { 'path' => 'tmp/tests/repositories' }, - 'picked' => { 'path' => 'tmp/tests/repositories' } + 'default' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/repositories'), + 'picked' => Gitlab::GitalyClient::StorageSettings.new('path' => 'tmp/tests/repositories') } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -3479,4 +3479,49 @@ describe Project do end end end + + describe "#pages_https_only?" do + subject { build(:project) } + + context "when HTTPS pages are disabled" do + it { is_expected.not_to be_pages_https_only } + end + + context "when HTTPS pages are enabled", :https_pages_enabled do + it { is_expected.to be_pages_https_only } + end + end + + describe "#pages_https_only? validation", :https_pages_enabled do + subject(:project) do + # set-up dirty object: + create(:project, pages_https_only: false).tap do |p| + p.pages_https_only = true + end + end + + context "when no domains are associated" do + it { is_expected.to be_valid } + end + + context "when domains including keys and certificates are associated" do + before do + allow(project) + .to receive(:pages_domains) + .and_return([instance_double(PagesDomain, https?: true)]) + end + + it { is_expected.to be_valid } + end + + context "when domains including no keys or certificates are associated" do + before do + allow(project) + .to receive(:pages_domains) + .and_return([instance_double(PagesDomain, https?: false)]) + end + + it { is_expected.not_to be_valid } + end + end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 5bc972bca14..60ab52565cb 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -501,28 +501,6 @@ describe Repository do end end - describe '#create_hooks' do - let(:hook_path) { File.join(repository.path_to_repo, 'hooks') } - - it 'symlinks the global hooks directory' do - repository.create_hooks - - expect(File.symlink?(hook_path)).to be true - expect(File.readlink(hook_path)).to eq(Gitlab.config.gitlab_shell.hooks_path) - end - - it 'replaces existing symlink with the right directory' do - FileUtils.mkdir_p(hook_path) - - expect(File.symlink?(hook_path)).to be false - - repository.create_hooks - - expect(File.symlink?(hook_path)).to be true - expect(File.readlink(hook_path)).to eq(Gitlab.config.gitlab_shell.hooks_path) - end - end - describe "#create_dir" do it "commits a change that creates a new directory" do expect do @@ -895,7 +873,7 @@ describe Repository do end it 'returns nil when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Copyright!', + repository.create_file(user, 'LICENSE', 'Gitlab B.V.', message: 'Add LICENSE', branch_name: 'master') expect(repository.license_key).to be_nil @@ -939,7 +917,7 @@ describe Repository do end it 'returns nil when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Copyright!', + repository.create_file(user, 'LICENSE', 'Gitlab B.V.', message: 'Add LICENSE', branch_name: 'master') expect(repository.license).to be_nil diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index dfac82b327a..01238a89a81 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -16,66 +16,6 @@ describe Route do it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_uniqueness_of(:path).case_insensitive } - - describe '#ensure_permanent_paths' do - context 'when the route is not yet persisted' do - let(:new_route) { described_class.new(path: 'foo', source: build(:group)) } - - context 'when permanent conflicting redirects exist' do - it 'is invalid' do - redirect = build(:redirect_route, :permanent, path: 'foo/bar/baz') - redirect.save!(validate: false) - - expect(new_route.valid?).to be_falsey - expect(new_route.errors.first[1]).to eq('has been taken before') - end - end - - context 'when no permanent conflicting redirects exist' do - it 'is valid' do - expect(new_route.valid?).to be_truthy - end - end - end - - context 'when path has changed' do - before do - route.path = 'foo' - end - - context 'when permanent conflicting redirects exist' do - it 'is invalid' do - redirect = build(:redirect_route, :permanent, path: 'foo/bar/baz') - redirect.save!(validate: false) - - expect(route.valid?).to be_falsey - expect(route.errors.first[1]).to eq('has been taken before') - end - end - - context 'when no permanent conflicting redirects exist' do - it 'is valid' do - expect(route.valid?).to be_truthy - end - end - end - - context 'when path has not changed' do - context 'when permanent conflicting redirects exist' do - it 'is valid' do - redirect = build(:redirect_route, :permanent, path: 'git_lab/foo/bar') - redirect.save!(validate: false) - - expect(route.valid?).to be_truthy - end - end - context 'when no permanent conflicting redirects exist' do - it 'is valid' do - expect(route.valid?).to be_truthy - end - end - end - end end describe 'callbacks' do @@ -211,43 +151,31 @@ describe Route do end context 'when the source is a Project' do - it 'creates a temporal RedirectRoute' do + it 'creates a RedirectRoute' do project = create(:project) route = project.route redirect_route = route.create_redirect('foo') - expect(redirect_route.permanent?).to be_falsy + expect(redirect_route).not_to be_nil end end context 'when the source is not a project' do - it 'creates a permanent RedirectRoute' do - redirect_route = route.create_redirect('foo', permanent: true) - expect(redirect_route.permanent?).to be_truthy + it 'creates a RedirectRoute' do + redirect_route = route.create_redirect('foo') + expect(redirect_route).not_to be_nil end end end describe '#delete_conflicting_redirects' do - context 'with permanent redirect' do - it 'does not delete the redirect' do - route.create_redirect("#{route.path}/foo", permanent: true) - - expect do - route.delete_conflicting_redirects - end.not_to change { RedirectRoute.count } - end - end - - context 'with temporal redirect' do - let(:route) { create(:project).route } + let(:route) { create(:project).route } - it 'deletes the redirect' do - route.create_redirect("#{route.path}/foo") + it 'deletes the redirect' do + route.create_redirect("#{route.path}/foo") - expect do - route.delete_conflicting_redirects - end.to change { RedirectRoute.count }.by(-1) - end + expect do + route.delete_conflicting_redirects + end.to change { RedirectRoute.count }.by(-1) end context 'when a redirect route with the same path exists' do @@ -289,31 +217,18 @@ describe Route do end describe '#conflicting_redirects' do + let(:route) { create(:project).route } + it 'returns an ActiveRecord::Relation' do expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation) end - context 'with permanent redirects' do - it 'does not return anything' do - route.create_redirect("#{route.path}/foo", permanent: true) - route.create_redirect("#{route.path}/foo/bar", permanent: true) - route.create_redirect("#{route.path}/baz/quz", permanent: true) + it 'returns the redirect routes' do + redirect1 = route.create_redirect("#{route.path}/foo") + redirect2 = route.create_redirect("#{route.path}/foo/bar") + redirect3 = route.create_redirect("#{route.path}/baz/quz") - expect(route.conflicting_redirects).to be_empty - end - end - - context 'with temporal redirects' do - let(:route) { create(:project).route } - - it 'returns the redirect routes' do - route = create(:project).route - redirect1 = route.create_redirect("#{route.path}/foo") - redirect2 = route.create_redirect("#{route.path}/foo/bar") - redirect3 = route.create_redirect("#{route.path}/baz/quz") - - expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3]) - end + expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3]) end context 'when a redirect route with the same path exists' do @@ -348,44 +263,6 @@ describe Route do end end - describe "#conflicting_redirect_exists?" do - context 'when a conflicting redirect exists' do - let(:group1) { create(:group, path: 'foo') } - let(:group2) { create(:group, path: 'baz') } - - it 'should not be saved' do - group1.path = 'bar' - group1.save - - group2.path = 'foo' - - expect(group2.save).to be_falsy - end - - it 'should return an error on path' do - group1.path = 'bar' - group1.save - - group2.path = 'foo' - group2.valid? - expect(group2.errors[:path]).to eq(['has been taken before']) - end - end - - context 'when a conflicting redirect does not exist' do - let(:project1) { create(:project, path: 'foo') } - let(:project2) { create(:project, path: 'baz') } - - it 'should be saved' do - project1.path = 'bar' - project1.save - - project2.path = 'foo' - expect(project2.save).to be_truthy - end - end - end - describe '#delete_conflicting_orphaned_routes' do context 'when there is a conflicting route' do let!(:conflicting_group) { create(:group, path: 'foo') } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5680eb24985..bbfdda23a31 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -126,23 +126,6 @@ describe User do end end - context 'when the username was used by another user before' do - let(:username) { 'foo' } - let!(:other_user) { create(:user, username: username) } - - before do - other_user.username = 'bar' - other_user.save! - end - - it 'is invalid' do - user = build(:user, username: username) - - expect(user).not_to be_valid - expect(user.errors.full_messages).to eq(['Username has been taken before']) - end - end - context 'when the username is in use by another user' do let(:username) { 'foo' } let!(:other_user) { create(:user, username: username) } @@ -1222,7 +1205,7 @@ describe User do it 'is false if avatar is html page' do user.update_attribute(:avatar, 'uploads/avatar.html') - expect(user.avatar_type).to eq(['only images allowed']) + expect(user.avatar_type).to eq(['file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff']) end end @@ -2699,27 +2682,19 @@ describe User do end end - describe "#username_previously_taken?" do - let(:user1) { create(:user, username: 'foo') } + context 'changing a username' do + let(:user) { create(:user, username: 'foo') } - context 'when the username has been taken before' do - before do - user1.username = 'bar' - user1.save! - end - - it 'should raise an ActiveRecord::RecordInvalid exception' do - user2 = build(:user, username: 'foo') - expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Username has been taken before/) - end + it 'creates a redirect route' do + expect { user.update!(username: 'bar') } + .to change { RedirectRoute.where(path: 'foo').count }.by(1) end - context 'when the username has not been taken before' do - it 'should be valid' do - expect(RedirectRoute.count).to eq(0) - user2 = build(:user, username: 'baz') - expect(user2).to be_valid - end + it 'deletes the redirect when a user with the old username was created' do + user.update!(username: 'bar') + + expect { create(:user, username: 'foo') } + .to change { RedirectRoute.where(path: 'foo').count }.by(-1) end end end diff --git a/spec/policies/protected_branch_policy_spec.rb b/spec/policies/protected_branch_policy_spec.rb new file mode 100644 index 00000000000..b39de42d721 --- /dev/null +++ b/spec/policies/protected_branch_policy_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe ProtectedBranchPolicy do + let(:user) { create(:user) } + let(:name) { 'feature' } + let(:protected_branch) { create(:protected_branch, name: name) } + let(:project) { protected_branch.project } + + subject { described_class.new(user, protected_branch) } + + it 'branches can be updated via project masters' do + project.add_master(user) + + is_expected.to be_allowed(:update_protected_branch) + end + + it "branches can't be updated by guests" do + project.add_guest(user) + + is_expected.to be_disallowed(:update_protected_branch) + end +end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index f8c93d91ec5..55962f345d4 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -339,7 +339,7 @@ describe ProjectPresenter do it 'returns link to clusters page if more than one exists' do project.add_master(user) - create(:cluster, projects: [project]) + create(:cluster, :production_environment, projects: [project]) create(:cluster, projects: [project]) expect(presenter.kubernetes_cluster_anchor_data).to eq(OpenStruct.new(enabled: true, diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 852f67db958..8ad19e3f0f5 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -1141,4 +1141,33 @@ describe API::Commits do end end end + + describe 'GET /projects/:id/repository/commits/:sha/merge_requests' do + let!(:project) { create(:project, :repository, :private) } + let!(:merged_mr) { create(:merge_request, source_project: project, source_branch: 'master', target_branch: 'feature') } + let(:commit) { merged_mr.merge_request_diff.commits.last } + + it 'returns the correct merge request' do + get api("/projects/#{project.id}/repository/commits/#{commit.id}/merge_requests", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response.length).to eq(1) + expect(json_response[0]['id']).to eq(merged_mr.id) + end + + it 'returns 403 for an unauthorized user' do + project.add_guest(user) + + get api("/projects/#{project.id}/repository/commits/#{commit.id}/merge_requests", user) + + expect(response).to have_gitlab_http_status(403) + end + + it 'responds 404 when the commit does not exist' do + get api("/projects/#{project.id}/repository/commits/a7d26f00c35b/merge_requests", user) + + expect(response).to have_gitlab_http_status(404) + end + end end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index ca0aac87ba9..3cb90a1b8ef 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -447,6 +447,12 @@ describe API::Internal do expect(response).to have_gitlab_http_status(200) expect(json_response["status"]).to be_truthy + expect(json_response["gitaly"]).not_to be_nil + expect(json_response["gitaly"]["repository"]).not_to be_nil + expect(json_response["gitaly"]["repository"]["storage_name"]).to eq(project.repository.gitaly_repository.storage_name) + expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path) + expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage)) + expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage)) end end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 6192bbd4abb..3ffdfdc0e9a 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe API::Jobs do + include HttpIOHelpers + set(:project) do create(:project, :repository, public_builds: false) end @@ -112,6 +114,7 @@ describe API::Jobs do let(:query) { Hash.new } before do + job get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), query end @@ -335,10 +338,55 @@ describe API::Jobs do end end + context 'when artifacts are stored remotely' do + let(:proxy_download) { false } + + before do + stub_artifacts_object_storage(proxy_download: proxy_download) + end + + let(:job) { create(:ci_build, pipeline: pipeline) } + let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: job) } + + before do + job.reload + + get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user) + end + + context 'when proxy download is enabled' do + let(:proxy_download) { true } + + it 'responds with the workhorse send-url' do + expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:") + end + end + + context 'when proxy download is disabled' do + it 'returns location redirect' do + expect(response).to have_gitlab_http_status(302) + end + end + + context 'authorized user' do + it 'returns the file remote URL' do + expect(response).to redirect_to(artifact.file.url) + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not return specific job artifacts' do + expect(response).to have_gitlab_http_status(404) + end + end + end + it 'does not return job artifacts if not uploaded' do get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user) - expect(response).to have_gitlab_http_status(404) + expect(response).to have_gitlab_http_status(:not_found) end end end @@ -349,6 +397,7 @@ describe API::Jobs do let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) } before do + stub_artifacts_object_storage job.success end @@ -412,9 +461,24 @@ describe API::Jobs do "attachment; filename=#{job.artifacts_file.filename}" } end - it { expect(response).to have_gitlab_http_status(200) } + it { expect(response).to have_http_status(:ok) } it { expect(response.headers).to include(download_headers) } end + + context 'when artifacts are stored remotely' do + let(:job) { create(:ci_build, pipeline: pipeline, user: api_user) } + let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: job) } + + before do + job.reload + + get api("/projects/#{project.id}/jobs/#{job.id}/artifacts", api_user) + end + + it 'returns location redirect' do + expect(response).to have_http_status(:found) + end + end end context 'with regular branch' do @@ -451,6 +515,22 @@ describe API::Jobs do end context 'authorized user' do + context 'when trace is in ObjectStorage' do + let!(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) } + + before do + stub_remote_trace_206 + allow_any_instance_of(JobArtifactUploader).to receive(:file_storage?) { false } + allow_any_instance_of(JobArtifactUploader).to receive(:url) { remote_trace_url } + allow_any_instance_of(JobArtifactUploader).to receive(:size) { remote_trace_size } + end + + it 'returns specific job trace' do + expect(response).to have_gitlab_http_status(200) + expect(response.body).to eq(job.trace.raw) + end + end + context 'when trace is artifact' do let(:job) { create(:ci_build, :trace_artifact, pipeline: pipeline) } diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb index dc3a116c060..a9ccbb32666 100644 --- a/spec/requests/api/pages_domains_spec.rb +++ b/spec/requests/api/pages_domains_spec.rb @@ -1,17 +1,17 @@ require 'rails_helper' describe API::PagesDomains do - set(:project) { create(:project, path: 'my.project') } + set(:project) { create(:project, path: 'my.project', pages_https_only: false) } set(:user) { create(:user) } set(:admin) { create(:admin) } - set(:pages_domain) { create(:pages_domain, domain: 'www.domain.test', project: project) } - set(:pages_domain_secure) { create(:pages_domain, :with_certificate, :with_key, domain: 'ssl.domain.test', project: project) } - set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, :with_key, domain: 'expired.domain.test', project: project) } + set(:pages_domain) { create(:pages_domain, :without_key, :without_certificate, domain: 'www.domain.test', project: project) } + set(:pages_domain_secure) { create(:pages_domain, domain: 'ssl.domain.test', project: project) } + set(:pages_domain_expired) { create(:pages_domain, :with_expired_certificate, domain: 'expired.domain.test', project: project) } - let(:pages_domain_params) { build(:pages_domain, domain: 'www.other-domain.test').slice(:domain) } - let(:pages_domain_secure_params) { build(:pages_domain, :with_certificate, :with_key, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) } - let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, :with_key, project: project).slice(:domain, :certificate, :key) } + let(:pages_domain_params) { build(:pages_domain, :without_key, :without_certificate, domain: 'www.other-domain.test').slice(:domain) } + let(:pages_domain_secure_params) { build(:pages_domain, domain: 'ssl.other-domain.test', project: project).slice(:domain, :certificate, :key) } + let(:pages_domain_secure_key_missmatch_params) {build(:pages_domain, :with_trusted_chain, project: project).slice(:domain, :certificate, :key) } let(:pages_domain_secure_missing_chain_params) {build(:pages_domain, :with_missing_chain, project: project).slice(:certificate) } let(:route) { "/projects/#{project.id}/pages/domains" } diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index cee93f6ed14..d73a42f48ad 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1718,6 +1718,12 @@ describe API::Projects do group end + let(:group3) do + group = create(:group, name: 'group3_name', parent: group2) + group.add_owner(user2) + group + end + before do project.add_reporter(user2) end @@ -1813,6 +1819,15 @@ describe API::Projects do expect(json_response['namespace']['name']).to eq(group2.name) end + it 'forks to owned subgroup' do + full_path = "#{group2.path}/#{group3.path}" + post api("/projects/#{project.id}/fork", user2), namespace: full_path + + expect(response).to have_gitlab_http_status(201) + expect(json_response['namespace']['name']).to eq(group3.name) + expect(json_response['namespace']['full_path']).to eq(full_path) + end + it 'fails to fork to not owned group' do post api("/projects/#{project.id}/fork", user2), namespace: group.name diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb index 1d23e023bb6..576fde46615 100644 --- a/spec/requests/api/protected_branches_spec.rb +++ b/spec/requests/api/protected_branches_spec.rb @@ -193,6 +193,19 @@ describe API::ProtectedBranches do expect(json_response['merge_access_levels'][0]['access_level']).to eq(Gitlab::Access::MASTER) end end + + context 'when a policy restricts rule deletion' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + expect(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents deletion of the protected branch rule" do + post post_endpoint, name: branch_name + + expect(response).to have_gitlab_http_status(403) + end + end end context 'when authenticated as a guest' do @@ -209,18 +222,20 @@ describe API::ProtectedBranches do end describe "DELETE /projects/:id/protected_branches/unprotect/:branch" do + let(:delete_endpoint) { api("/projects/#{project.id}/protected_branches/#{branch_name}", user) } + before do project.add_master(user) end it "unprotects a single branch" do - delete api("/projects/#{project.id}/protected_branches/#{branch_name}", user) + delete delete_endpoint expect(response).to have_gitlab_http_status(204) end it_behaves_like '412 response' do - let(:request) { api("/projects/#{project.id}/protected_branches/#{branch_name}", user) } + let(:request) { delete_endpoint } end it "returns 404 if branch does not exist" do @@ -229,11 +244,24 @@ describe API::ProtectedBranches do expect(response).to have_gitlab_http_status(404) end + context 'when a policy restricts rule deletion' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + expect(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents deletion of the protected branch rule" do + delete delete_endpoint + + expect(response).to have_gitlab_http_status(403) + end + end + context 'when branch has a wildcard in its name' do let(:protected_name) { 'feature*' } it "unprotects a wildcard branch" do - delete api("/projects/#{project.id}/protected_branches/#{branch_name}", user) + delete delete_endpoint expect(response).to have_gitlab_http_status(204) end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 95c23726a79..f3dd121faa9 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -200,7 +200,7 @@ describe API::Runner do let(:project) { create(:project, shared_runners_enabled: false) } let(:pipeline) { create(:ci_pipeline_without_jobs, project: project, ref: 'master') } let(:runner) { create(:ci_runner) } - let!(:job) do + let(:job) do create(:ci_build, :artifacts, :extended_options, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, commands: "ls\ndate") end @@ -215,6 +215,7 @@ describe API::Runner do let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' } before do + job stub_container_registry_config(enabled: false) end @@ -888,6 +889,7 @@ describe API::Runner do let(:file_upload2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/gif') } before do + stub_artifacts_object_storage job.run! end @@ -1179,27 +1181,67 @@ describe API::Runner do describe 'GET /api/v4/jobs/:id/artifacts' do let(:token) { job.token } - before do - download_artifact - end - context 'when job has artifacts' do - let(:job) { create(:ci_build, :artifacts) } - let(:download_headers) do - { 'Content-Transfer-Encoding' => 'binary', - 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + let(:job) { create(:ci_build) } + let(:store) { JobArtifactUploader::Store::LOCAL } + + before do + create(:ci_job_artifact, :archive, file_store: store, job: job) end context 'when using job token' do - it 'download artifacts' do - expect(response).to have_gitlab_http_status(200) - expect(response.headers).to include download_headers + context 'when artifacts are stored locally' do + let(:download_headers) do + { 'Content-Transfer-Encoding' => 'binary', + 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + end + + before do + download_artifact + end + + it 'download artifacts' do + expect(response).to have_http_status(200) + expect(response.headers).to include download_headers + end + end + + context 'when artifacts are stored remotely' do + let(:store) { JobArtifactUploader::Store::REMOTE } + let!(:job) { create(:ci_build) } + + context 'when proxy download is being used' do + before do + download_artifact(direct_download: false) + end + + it 'uses workhorse send-url' do + expect(response).to have_gitlab_http_status(200) + expect(response.headers).to include( + 'Gitlab-Workhorse-Send-Data' => /send-url:/) + end + end + + context 'when direct download is being used' do + before do + download_artifact(direct_download: true) + end + + it 'receive redirect for downloading artifacts' do + expect(response).to have_gitlab_http_status(302) + expect(response.headers).to include('Location') + end + end end end context 'when using runnners token' do let(:token) { job.project.runners_token } + before do + download_artifact + end + it 'responds with forbidden' do expect(response).to have_gitlab_http_status(403) end @@ -1208,12 +1250,16 @@ describe API::Runner do context 'when job does not has artifacts' do it 'responds with not found' do + download_artifact + expect(response).to have_gitlab_http_status(404) end end def download_artifact(params = {}, request_headers = headers) params = params.merge(token: token) + job.reload + get api("/jobs/#{job.id}/artifacts"), params, request_headers end end diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index 9052a18c60b..f8d5258a8d9 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -99,10 +99,10 @@ describe API::Search do end end - describe "GET /groups/:id/-/search" do + describe "GET /groups/:id/search" do context 'when user is not authenticated' do it 'returns 401 error' do - get api("/groups/#{group.id}/-/search"), scope: 'projects', search: 'awesome' + get api("/groups/#{group.id}/search"), scope: 'projects', search: 'awesome' expect(response).to have_gitlab_http_status(401) end @@ -110,7 +110,7 @@ describe API::Search do context 'when scope is not supported' do it 'returns 400 error' do - get api("/groups/#{group.id}/-/search", user), scope: 'unsupported', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'unsupported', search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -118,7 +118,7 @@ describe API::Search do context 'when scope is missing' do it 'returns 400 error' do - get api("/groups/#{group.id}/-/search", user), search: 'awesome' + get api("/groups/#{group.id}/search", user), search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -126,7 +126,7 @@ describe API::Search do context 'when group does not exist' do it 'returns 404 error' do - get api('/groups/9999/-/search', user), scope: 'issues', search: 'awesome' + get api('/groups/9999/search', user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -136,7 +136,7 @@ describe API::Search do it 'returns 404 error' do private_group = create(:group, :private) - get api("/groups/#{private_group.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/groups/#{private_group.id}/search", user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -145,7 +145,7 @@ describe API::Search do context 'with correct params' do context 'for projects scope' do before do - get api("/groups/#{group.id}/-/search", user), scope: 'projects', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'projects', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/projects' @@ -155,7 +155,7 @@ describe API::Search do before do create(:issue, project: project, title: 'awesome issue') - get api("/groups/#{group.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'issues', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/issues' @@ -165,7 +165,7 @@ describe API::Search do before do create(:merge_request, source_project: repo_project, title: 'awesome mr') - get api("/groups/#{group.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'merge_requests', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' @@ -175,7 +175,7 @@ describe API::Search do before do create(:milestone, project: project, title: 'awesome milestone') - get api("/groups/#{group.id}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -187,7 +187,7 @@ describe API::Search do create(:milestone, project: project, title: 'awesome milestone') create(:milestone, project: another_project, title: 'awesome milestone other project') - get api("/groups/#{CGI.escape(group.full_path)}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/groups/#{CGI.escape(group.full_path)}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -198,7 +198,7 @@ describe API::Search do describe "GET /projects/:id/search" do context 'when user is not authenticated' do it 'returns 401 error' do - get api("/projects/#{project.id}/-/search"), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search"), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(401) end @@ -206,7 +206,7 @@ describe API::Search do context 'when scope is not supported' do it 'returns 400 error' do - get api("/projects/#{project.id}/-/search", user), scope: 'unsupported', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'unsupported', search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -214,7 +214,7 @@ describe API::Search do context 'when scope is missing' do it 'returns 400 error' do - get api("/projects/#{project.id}/-/search", user), search: 'awesome' + get api("/projects/#{project.id}/search", user), search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -222,7 +222,7 @@ describe API::Search do context 'when project does not exist' do it 'returns 404 error' do - get api('/projects/9999/-/search', user), scope: 'issues', search: 'awesome' + get api('/projects/9999/search', user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -232,7 +232,7 @@ describe API::Search do it 'returns 404 error' do project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -243,7 +243,7 @@ describe API::Search do before do create(:issue, project: project, title: 'awesome issue') - get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/issues' @@ -253,7 +253,7 @@ describe API::Search do before do create(:merge_request, source_project: repo_project, title: 'awesome mr') - get api("/projects/#{repo_project.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + get api("/projects/#{repo_project.id}/search", user), scope: 'merge_requests', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' @@ -263,7 +263,7 @@ describe API::Search do before do create(:milestone, project: project, title: 'awesome milestone') - get api("/projects/#{project.id}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -273,7 +273,7 @@ describe API::Search do before do create(:note_on_merge_request, project: project, note: 'awesome note') - get api("/projects/#{project.id}/-/search", user), scope: 'notes', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'notes', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/notes' @@ -284,7 +284,7 @@ describe API::Search do wiki = create(:project_wiki, project: project) create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" }) - get api("/projects/#{project.id}/-/search", user), scope: 'wiki_blobs', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'wiki_blobs', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/blobs' @@ -292,7 +292,7 @@ describe API::Search do context 'for commits scope' do before do - get api("/projects/#{repo_project.id}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + get api("/projects/#{repo_project.id}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' end it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details' @@ -300,7 +300,7 @@ describe API::Search do context 'for commits scope with project path as id' do before do - get api("/projects/#{CGI.escape(repo_project.full_path)}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + get api("/projects/#{CGI.escape(repo_project.full_path)}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' end it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details' @@ -308,7 +308,7 @@ describe API::Search do context 'for blobs scope' do before do - get api("/projects/#{repo_project.id}/-/search", user), scope: 'blobs', search: 'monitors' + get api("/projects/#{repo_project.id}/search", user), scope: 'blobs', search: 'monitors' end it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2 diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index de1619f33c1..6bb53fdc98d 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -65,7 +65,7 @@ describe API::Templates do expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) - expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['limitations']).to eq(%w[liability warranty]) expect(json_response['content']).to include('MIT License') end end diff --git a/spec/requests/api/v3/builds_spec.rb b/spec/requests/api/v3/builds_spec.rb index 79041c6a792..00f067889a0 100644 --- a/spec/requests/api/v3/builds_spec.rb +++ b/spec/requests/api/v3/builds_spec.rb @@ -216,6 +216,7 @@ describe API::V3::Builds do describe 'GET /projects/:id/builds/:build_id/artifacts' do before do + stub_artifacts_object_storage get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) end @@ -230,13 +231,24 @@ describe API::V3::Builds do end it 'returns specific job artifacts' do - expect(response).to have_gitlab_http_status(200) + expect(response).to have_http_status(200) expect(response.headers).to include(download_headers) expect(response.body).to match_file(build.artifacts_file.file.file) end end end + context 'when artifacts are stored remotely' do + let(:build) { create(:ci_build, pipeline: pipeline) } + let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: build) } + + it 'returns location redirect' do + get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) + + expect(response).to have_gitlab_http_status(302) + end + end + context 'unauthorized user' do let(:api_user) { nil } @@ -256,6 +268,7 @@ describe API::V3::Builds do let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } before do + stub_artifacts_object_storage build.success end @@ -318,9 +331,24 @@ describe API::V3::Builds do "attachment; filename=#{build.artifacts_file.filename}" } end - it { expect(response).to have_gitlab_http_status(200) } + it { expect(response).to have_http_status(200) } it { expect(response.headers).to include(download_headers) } end + + context 'when artifacts are stored remotely' do + let(:build) { create(:ci_build, pipeline: pipeline) } + let!(:artifact) { create(:ci_job_artifact, :archive, :remote_store, job: build) } + + before do + build.reload + + get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) + end + + it 'returns location redirect' do + expect(response).to have_http_status(302) + end + end end context 'with regular branch' do diff --git a/spec/requests/api/v3/templates_spec.rb b/spec/requests/api/v3/templates_spec.rb index 38a8994eb79..1a637f3cf96 100644 --- a/spec/requests/api/v3/templates_spec.rb +++ b/spec/requests/api/v3/templates_spec.rb @@ -57,7 +57,7 @@ describe API::V3::Templates do expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) - expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['limitations']).to eq(%w[liability warranty]) expect(json_response['content']).to include('MIT License') end end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 6dbbb1ad7bb..494db30e8e0 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -163,7 +163,7 @@ describe 'Git HTTP requests' do download(path) do |response| json_body = ActiveSupport::JSON.decode(response.body) - expect(json_body['RepoPath']).to include(wiki.repository.disk_path) + expect(json_body['Repository']['relative_path']).to eq(wiki.repository.relative_path) end end end @@ -344,20 +344,11 @@ describe 'Git HTTP requests' do context 'and the user requests a redirected path' do let!(:redirect) { project.route.create_redirect('foo/bar') } let(:path) { "#{redirect.path}.git" } - let(:project_moved_message) do - <<-MSG.strip_heredoc - Project '#{redirect.path}' was moved to '#{project.full_path}'. - Please update your Git remote: - - git remote set-url origin #{project.http_url_to_repo} and try again. - MSG - end - - it 'downloads get status 404 with "project was moved" message' do + it 'downloads get status 200 for redirects' do clone_get(path, {}) - expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to match(project_moved_message) + + expect(response).to have_gitlab_http_status(:ok) end end end @@ -559,20 +550,19 @@ describe 'Git HTTP requests' do Please update your Git remote: - git remote set-url origin #{project.http_url_to_repo} and try again. + git remote set-url origin #{project.http_url_to_repo}. MSG end - it 'downloads get status 404 with "project was moved" message' do + it 'downloads get status 200' do clone_get(path, env) - expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to match(project_moved_message) + + expect(response).to have_gitlab_http_status(:ok) end it 'uploads get status 404 with "project was moved" message' do upload(path, env) do |response| - expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to match(project_moved_message) + expect(response).to have_gitlab_http_status(:ok) end end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index 971b45c411d..1e6bd993c08 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -191,10 +191,12 @@ describe 'Git LFS API and storage' do describe 'when fetching lfs object' do let(:project) { create(:project) } let(:update_permissions) { } + let(:before_get) { } before do enable_lfs update_permissions + before_get get "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", nil, headers end @@ -239,6 +241,38 @@ describe 'Git LFS API and storage' do end it_behaves_like 'responds with a file' + + context 'when LFS uses object storage' do + context 'when proxy download is enabled' do + let(:before_get) do + stub_lfs_object_storage(proxy_download: true) + lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE) + end + + it 'responds with redirect' do + expect(response).to have_gitlab_http_status(200) + end + + it 'responds with the workhorse send-url' do + expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:") + end + end + + context 'when proxy download is disabled' do + let(:before_get) do + stub_lfs_object_storage(proxy_download: false) + lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE) + end + + it 'responds with redirect' do + expect(response).to have_gitlab_http_status(302) + end + + it 'responds with the file location' do + expect(response.location).to include(lfs_object.reload.file.path) + end + end + end end end @@ -945,22 +979,61 @@ describe 'Git LFS API and storage' do end context 'and request is sent by gitlab-workhorse to authorize the request' do - before do - put_authorize + shared_examples 'a valid response' do + before do + put_authorize + end + + it 'responds with status 200' do + expect(response).to have_gitlab_http_status(200) + end + + it 'uses the gitlab-workhorse content type' do + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end end - it 'responds with status 200' do - expect(response).to have_gitlab_http_status(200) + shared_examples 'a local file' do + it_behaves_like 'a valid response' do + it 'responds with status 200, location of lfs store and object details' do + expect(json_response['TempPath']).to eq(LfsObjectUploader.workhorse_local_upload_path) + expect(json_response['RemoteObject']).to be_nil + expect(json_response['LfsOid']).to eq(sample_oid) + expect(json_response['LfsSize']).to eq(sample_size) + end + end end - it 'uses the gitlab-workhorse content type' do - expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + context 'when using local storage' do + it_behaves_like 'a local file' end - it 'responds with status 200, location of lfs store and object details' do - expect(json_response['StoreLFSPath']).to eq(LfsObjectUploader.workhorse_upload_path) - expect(json_response['LfsOid']).to eq(sample_oid) - expect(json_response['LfsSize']).to eq(sample_size) + context 'when using remote storage' do + context 'when direct upload is enabled' do + before do + stub_lfs_object_storage(enabled: true, direct_upload: true) + end + + it_behaves_like 'a valid response' do + it 'responds with status 200, location of lfs remote store and object details' do + expect(json_response['TempPath']).to be_nil + expect(json_response['RemoteObject']).to have_key('ID') + expect(json_response['RemoteObject']).to have_key('GetURL') + expect(json_response['RemoteObject']).to have_key('StoreURL') + expect(json_response['RemoteObject']).to have_key('DeleteURL') + expect(json_response['LfsOid']).to eq(sample_oid) + expect(json_response['LfsSize']).to eq(sample_size) + end + end + end + + context 'when direct upload is disabled' do + before do + stub_lfs_object_storage(enabled: true, direct_upload: false) + end + + it_behaves_like 'a local file' + end end end @@ -978,14 +1051,95 @@ describe 'Git LFS API and storage' do end end + context 'and workhorse requests upload finalize for a new lfs object' do + before do + lfs_object.destroy + end + + context 'with object storage disabled' do + it "doesn't attempt to migrate file to object storage" do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + put_finalize(with_tempfile: true) + end + end + + context 'with object storage enabled' do + context 'and direct upload enabled' do + let!(:fog_connection) do + stub_lfs_object_storage(direct_upload: true) + end + + ['123123', '../../123123'].each do |remote_id| + context "with invalid remote_id: #{remote_id}" do + subject do + put_finalize_with_args('file.remote_id' => remote_id) + end + + it 'responds with status 403' do + subject + + expect(response).to have_gitlab_http_status(403) + end + end + end + + context 'with valid remote_id' do + before do + fog_connection.directories.get('lfs-objects').files.create( + key: 'tmp/upload/12312300', + body: 'content' + ) + end + + subject do + put_finalize_with_args( + 'file.remote_id' => '12312300', + 'file.name' => 'name') + end + + it 'responds with status 200' do + subject + + expect(response).to have_gitlab_http_status(200) + end + + it 'schedules migration of file to object storage' do + subject + + expect(LfsObject.last.projects).to include(project) + end + + it 'have valid file' do + subject + + expect(LfsObject.last.file_store).to eq(ObjectStorage::Store::REMOTE) + expect(LfsObject.last.file).to be_exists + end + end + end + + context 'and background upload enabled' do + before do + stub_lfs_object_storage(background_upload: true) + end + + it 'schedules migration of file to object storage' do + expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with('LfsObjectUploader', 'LfsObject', :file, kind_of(Numeric)) + + put_finalize(with_tempfile: true) + end + end + end + end + context 'invalid tempfiles' do - it 'rejects slashes in the tempfile name (path traversal' do - put_finalize('foo/bar') - expect(response).to have_gitlab_http_status(403) + before do + lfs_object.destroy end - it 'rejects tempfile names that do not start with the oid' do - put_finalize("foo#{sample_oid}") + it 'rejects slashes in the tempfile name (path traversal)' do + put_finalize('../bar', with_tempfile: true) expect(response).to have_gitlab_http_status(403) end end @@ -1075,7 +1229,7 @@ describe 'Git LFS API and storage' do end it 'with location of lfs store and object details' do - expect(json_response['StoreLFSPath']).to eq(LfsObjectUploader.workhorse_upload_path) + expect(json_response['TempPath']).to eq(LfsObjectUploader.workhorse_local_upload_path) expect(json_response['LfsOid']).to eq(sample_oid) expect(json_response['LfsSize']).to eq(sample_size) end @@ -1177,9 +1331,25 @@ describe 'Git LFS API and storage' do put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}/authorize", nil, authorize_headers end - def put_finalize(lfs_tmp = lfs_tmp_file) - put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", nil, - headers.merge('X-Gitlab-Lfs-Tmp' => lfs_tmp).compact + def put_finalize(lfs_tmp = lfs_tmp_file, with_tempfile: false) + upload_path = LfsObjectUploader.workhorse_local_upload_path + file_path = upload_path + '/' + lfs_tmp if lfs_tmp + + if with_tempfile + FileUtils.mkdir_p(upload_path) + FileUtils.touch(file_path) + end + + args = { + 'file.path' => file_path, + 'file.name' => File.basename(file_path) + }.compact + + put_finalize_with_args(args) + end + + def put_finalize_with_args(args) + put "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}/#{sample_size}", args, headers end def lfs_tmp_file diff --git a/spec/rubocop/cop/gitlab/httparty_spec.rb b/spec/rubocop/cop/gitlab/httparty_spec.rb new file mode 100644 index 00000000000..510839a21d7 --- /dev/null +++ b/spec/rubocop/cop/gitlab/httparty_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../../rubocop/cop/gitlab/httparty' + +describe RuboCop::Cop::Gitlab::HTTParty do # rubocop:disable RSpec/FilePath + include CopHelper + + subject(:cop) { described_class.new } + + shared_examples('registering include offense') do |options| + let(:offending_lines) { options[:offending_lines] } + + it 'registers an offense when the class includes HTTParty' do + inspect_source(source) + + aggregate_failures do + expect(cop.offenses.size).to eq(offending_lines.size) + expect(cop.offenses.map(&:line)).to eq(offending_lines) + end + end + end + + shared_examples('registering call offense') do |options| + let(:offending_lines) { options[:offending_lines] } + + it 'registers an offense when the class calls HTTParty' do + inspect_source(source) + + aggregate_failures do + expect(cop.offenses.size).to eq(offending_lines.size) + expect(cop.offenses.map(&:line)).to eq(offending_lines) + end + end + end + + context 'when source is a regular module' do + it_behaves_like 'registering include offense', offending_lines: [2] do + let(:source) do + <<~RUBY + module M + include HTTParty + end + RUBY + end + end + end + + context 'when source is a regular class' do + it_behaves_like 'registering include offense', offending_lines: [2] do + let(:source) do + <<~RUBY + class Foo + include HTTParty + end + RUBY + end + end + end + + context 'when HTTParty is called' do + it_behaves_like 'registering call offense', offending_lines: [3] do + let(:source) do + <<~RUBY + class Foo + def bar + HTTParty.get('http://example.com') + end + end + RUBY + end + end + end +end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index c38795ad1a1..f51c11b141f 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -117,6 +117,7 @@ describe PipelineSerializer do shared_examples 'no N+1 queries' do it 'verifies number of queries', :request_store do recorded = ActiveRecord::QueryRecorder.new { subject } + expect(recorded.count).to be_within(1).of(36) expect(recorded.cached_count).to eq(0) end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index 0ce41e7c7ee..feb5120bc68 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -9,6 +9,8 @@ describe Ci::ProcessPipelineService, '#execute' do end before do + stub_ci_pipeline_to_return_yaml_file + stub_not_protect_default_branch project.add_developer(user) diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index db9c216d3f4..b86a3d72bb4 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -28,7 +28,8 @@ describe Ci::RetryBuildService do %i[type lock_version target_url base_tags trace_sections commit_id deployments erased_by_id last_deployment project_id runner_id tag_taggings taggings tags trigger_request_id - user_id auto_canceled_by_id retried failure_reason].freeze + user_id auto_canceled_by_id retried failure_reason + artifacts_file_store artifacts_metadata_store].freeze shared_examples 'build duplication' do let(:another_pipeline) { create(:ci_empty_pipeline, project: project) } diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb index e2e64659dfa..1c2f9c5cf43 100644 --- a/spec/services/clusters/create_service_spec.rb +++ b/spec/services/clusters/create_service_spec.rb @@ -82,7 +82,7 @@ describe Clusters::CreateService do context 'when project has a cluster' do include_context 'valid params' - let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + let!(:cluster) { create(:cluster, :provided_by_gcp, :production_environment, projects: [project]) } it 'does not create a cluster' do expect(ClusterProvisionWorker).not_to receive(:perform_async) diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index e1c873f8c1e..999677cfaaa 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -222,8 +222,8 @@ describe Groups::TransferService, :postgresql do expect(new_parent_group.children.first).to eq(group) end - it 'should create a permanent redirect for the group' do - expect(group.redirect_routes.permanent.count).to eq(1) + it 'should create a redirect for the group' do + expect(group.redirect_routes.count).to eq(1) end end @@ -243,10 +243,10 @@ describe Groups::TransferService, :postgresql do end end - it 'should create permanent redirects for the subgroups' do - expect(group.redirect_routes.permanent.count).to eq(1) - expect(subgroup1.redirect_routes.permanent.count).to eq(1) - expect(subgroup2.redirect_routes.permanent.count).to eq(1) + it 'should create redirects for the subgroups' do + expect(group.redirect_routes.count).to eq(1) + expect(subgroup1.redirect_routes.count).to eq(1) + expect(subgroup2.redirect_routes.count).to eq(1) end context 'when the new parent has a higher visibility than the children' do @@ -287,9 +287,9 @@ describe Groups::TransferService, :postgresql do end it 'should create permanent redirects for the projects' do - expect(group.redirect_routes.permanent.count).to eq(1) - expect(project1.redirect_routes.permanent.count).to eq(1) - expect(project2.redirect_routes.permanent.count).to eq(1) + expect(group.redirect_routes.count).to eq(1) + expect(project1.redirect_routes.count).to eq(1) + expect(project2.redirect_routes.count).to eq(1) end context 'when the new parent has a higher visibility than the projects' do @@ -338,12 +338,12 @@ describe Groups::TransferService, :postgresql do end end - it 'should create permanent redirect for the subgroups and projects' do - expect(group.redirect_routes.permanent.count).to eq(1) - expect(subgroup1.redirect_routes.permanent.count).to eq(1) - expect(subgroup2.redirect_routes.permanent.count).to eq(1) - expect(project1.redirect_routes.permanent.count).to eq(1) - expect(project2.redirect_routes.permanent.count).to eq(1) + it 'should create redirect for the subgroups and projects' do + expect(group.redirect_routes.count).to eq(1) + expect(subgroup1.redirect_routes.count).to eq(1) + expect(subgroup2.redirect_routes.count).to eq(1) + expect(project1.redirect_routes.count).to eq(1) + expect(project2.redirect_routes.count).to eq(1) end end @@ -380,12 +380,12 @@ describe Groups::TransferService, :postgresql do end end - it 'should create permanent redirect for the subgroups and projects' do - expect(group.redirect_routes.permanent.count).to eq(1) - expect(project1.redirect_routes.permanent.count).to eq(1) - expect(subgroup1.redirect_routes.permanent.count).to eq(1) - expect(nested_subgroup.redirect_routes.permanent.count).to eq(1) - expect(nested_project.redirect_routes.permanent.count).to eq(1) + it 'should create redirect for the subgroups and projects' do + expect(group.redirect_routes.count).to eq(1) + expect(project1.redirect_routes.count).to eq(1) + expect(subgroup1.redirect_routes.count).to eq(1) + expect(nested_subgroup.redirect_routes.count).to eq(1) + expect(nested_project.redirect_routes.count).to eq(1) end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index c148a98569b..a9aee9e100f 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -6,7 +6,7 @@ describe Issues::MoveService do let(:title) { 'Some issue' } let(:description) { 'Some issue description' } let(:old_project) { create(:project) } - let(:new_project) { create(:project, group: create(:group)) } + let(:new_project) { create(:project) } let(:milestone1) { create(:milestone, project_id: old_project.id, title: 'v9.0') } let(:old_issue) do diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 903aa0a5078..2536c6e2514 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -24,6 +24,14 @@ describe MergeRequests::RefreshService do merge_when_pipeline_succeeds: true, merge_user: @user) + @another_merge_request = create(:merge_request, + source_project: @project, + source_branch: 'master', + target_branch: 'test', + target_project: @project, + merge_when_pipeline_succeeds: true, + merge_user: @user) + @fork_merge_request = create(:merge_request, source_project: @fork_project, source_branch: 'master', @@ -52,9 +60,11 @@ describe MergeRequests::RefreshService do context 'push to origin repo source branch' do let(:refresh_service) { service.new(@project, @user) } + let(:notification_service) { spy('notification_service') } before do allow(refresh_service).to receive(:execute_hooks) + allow(NotificationService).to receive(:new) { notification_service } end it 'executes hooks with update action' do @@ -64,6 +74,11 @@ describe MergeRequests::RefreshService do expect(refresh_service).to have_received(:execute_hooks) .with(@merge_request, 'update', old_rev: @oldrev) + expect(notification_service).to have_received(:push_to_merge_request) + .with(@merge_request, @user, new_commits: anything, existing_commits: anything) + expect(notification_service).to have_received(:push_to_merge_request) + .with(@another_merge_request, @user, new_commits: anything, existing_commits: anything) + expect(@merge_request.notes).not_to be_empty expect(@merge_request).to be_open expect(@merge_request.merge_when_pipeline_succeeds).to be_falsey @@ -119,11 +134,13 @@ describe MergeRequests::RefreshService do context 'push to origin repo source branch when an MR was reopened' do let(:refresh_service) { service.new(@project, @user) } + let(:notification_service) { spy('notification_service') } before do @merge_request.update(state: :reopened) allow(refresh_service).to receive(:execute_hooks) + allow(NotificationService).to receive(:new) { notification_service } refresh_service.execute(@oldrev, @newrev, 'refs/heads/master') reload_mrs end @@ -131,6 +148,10 @@ describe MergeRequests::RefreshService do it 'executes hooks with update action' do expect(refresh_service).to have_received(:execute_hooks) .with(@merge_request, 'update', old_rev: @oldrev) + expect(notification_service).to have_received(:push_to_merge_request) + .with(@merge_request, @user, new_commits: anything, existing_commits: anything) + expect(notification_service).to have_received(:push_to_merge_request) + .with(@another_merge_request, @user, new_commits: anything, existing_commits: anything) expect(@merge_request.notes).not_to be_empty expect(@merge_request).to be_open diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 62fdf870090..f8fa2540804 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -34,6 +34,12 @@ describe NotificationService, :mailer do should_not_email_anyone end + it 'emails new mentions despite being unsubscribed' do + send_notifications(@unsubscribed_mentioned) + + should_only_email(@unsubscribed_mentioned) + end + it 'sends the proper notification reason header' do send_notifications(@u_watcher) should_only_email(@u_watcher) @@ -122,7 +128,7 @@ describe NotificationService, :mailer do let(:project) { create(:project, :private) } let(:issue) { create(:issue, project: project, assignees: [assignee]) } let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } - let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } + let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } before do build_team(note.project) @@ -150,7 +156,7 @@ describe NotificationService, :mailer do add_users_with_subscription(note.project, issue) reset_delivered_emails! - expect(SentNotification).to receive(:record).with(issue, any_args).exactly(9).times + expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times notification.new_note(note) @@ -163,6 +169,7 @@ describe NotificationService, :mailer do should_email(@watcher_and_subscriber) should_email(@subscribed_participant) should_email(@u_custom_off) + should_email(@unsubscribed_mentioned) should_not_email(@u_guest_custom) should_not_email(@u_guest_watcher) should_not_email(note.author) @@ -279,6 +286,7 @@ describe NotificationService, :mailer do before do build_team(note.project) note.project.add_master(note.author) + add_users_with_subscription(note.project, issue) reset_delivered_emails! end @@ -286,6 +294,9 @@ describe NotificationService, :mailer do it 'notifies the team members' do notification.new_note(note) + # Make sure @unsubscribed_mentioned is part of the team + expect(note.project.team.members).to include(@unsubscribed_mentioned) + # Notify all team members note.project.team.members.each do |member| # User with disabled notification should not be notified @@ -486,7 +497,7 @@ describe NotificationService, :mailer do let(:group) { create(:group) } let(:project) { create(:project, :public, namespace: group) } let(:another_project) { create(:project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' } before do build_team(issue.project) @@ -510,6 +521,7 @@ describe NotificationService, :mailer do should_email(@u_participant_mentioned) should_email(@g_global_watcher) should_email(@g_watcher) + should_email(@unsubscribed_mentioned) should_not_email(@u_mentioned) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -1078,6 +1090,36 @@ describe NotificationService, :mailer do end end + describe '#push_to_merge_request' do + before do + update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project) + update_custom_notification(:push_to_merge_request, @u_custom_global) + end + + it do + notification.push_to_merge_request(merge_request, @u_disabled) + + should_email(merge_request.assignee) + should_email(@u_guest_custom) + should_email(@u_custom_global) + should_email(@u_participant_mentioned) + should_email(@subscriber) + should_email(@watcher_and_subscriber) + should_not_email(@u_watcher) + should_not_email(@u_guest_watcher) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + it_behaves_like 'participating notifications' do + let(:participant) { create(:user, username: 'user-participant') } + let(:issuable) { merge_request } + let(:notification_trigger) { notification.push_to_merge_request(merge_request, @u_disabled) } + end + end + describe '#relabel_merge_request' do let(:group_label_1) { create(:group_label, group: group, title: 'Group Label 1', merge_requests: [merge_request]) } let(:group_label_2) { create(:group_label, group: group, title: 'Group Label 2') } @@ -1823,6 +1865,7 @@ describe NotificationService, :mailer do def add_users_with_subscription(project, issuable) @subscriber = create :user @unsubscriber = create :user + @unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned' @subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating) @watcher_and_subscriber = create_global_setting_for(create(:user), :watch) @@ -1830,7 +1873,9 @@ describe NotificationService, :mailer do project.add_master(@subscriber) project.add_master(@unsubscriber) project.add_master(@watcher_and_subscriber) + project.add_master(@unsubscribed_mentioned) + issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false) issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true) issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true) issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 8471467d2fa..4413c6ef83e 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -153,7 +153,7 @@ describe Projects::CreateService, '#execute' do context 'when another repository already exists on disk' do let(:repository_storage) { 'default' } - let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } let(:opts) do { diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index d1011b07db6..0f7c46367d0 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -105,7 +105,7 @@ describe Projects::ForkService do context 'repository already exists' do let(:repository_storage) { 'default' } - let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } before do gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}") diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index ce567fe3879..95a6771c59d 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -146,7 +146,7 @@ describe Projects::TransferService do context 'namespace which contains orphan repository with same projects path name' do let(:repository_storage) { 'default' } - let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } before do group.add_owner(user) diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index f3f97b6b921..f48d466d263 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -190,7 +190,7 @@ describe Projects::UpdateService do context 'when renaming a project' do let(:repository_storage) { 'default' } - let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage].legacy_disk_path } context 'with legacy storage' do let(:project) { create(:project, :legacy_storage, :repository, creator: user, namespace: user.namespace) } @@ -241,6 +241,27 @@ describe Projects::UpdateService do }) end end + + context 'when updating #pages_https_only', :https_pages_enabled do + subject(:call_service) do + update_project(project, admin, pages_https_only: false) + end + + it 'updates the attribute' do + expect { call_service } + .to change { project.pages_https_only? } + .to(false) + end + + it 'calls Projects::UpdatePagesConfigurationService' do + expect(Projects::UpdatePagesConfigurationService) + .to receive(:new) + .with(project) + .and_call_original + + call_service + end + end end describe '#run_auto_devops_pipeline?' do diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb index 53b3e5e365d..786493c3577 100644 --- a/spec/services/protected_branches/create_service_spec.rb +++ b/spec/services/protected_branches/create_service_spec.rb @@ -35,5 +35,18 @@ describe ProtectedBranches::CreateService do expect { service.execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end + + context 'when a policy restricts rule creation' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + expect(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents creation of the protected branch rule" do + expect do + service.execute + end.to raise_error(Gitlab::Access::AccessDeniedError) + end + end end end diff --git a/spec/services/protected_branches/destroy_service_spec.rb b/spec/services/protected_branches/destroy_service_spec.rb new file mode 100644 index 00000000000..4a391b6c25c --- /dev/null +++ b/spec/services/protected_branches/destroy_service_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe ProtectedBranches::DestroyService do + let(:protected_branch) { create(:protected_branch) } + let(:project) { protected_branch.project } + let(:user) { project.owner } + + describe '#execute' do + subject(:service) { described_class.new(project, user) } + + it 'destroys a protected branch' do + service.execute(protected_branch) + + expect(protected_branch).to be_destroyed + end + + context 'when a policy restricts rule deletion' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + expect(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents deletion of the protected branch rule" do + expect do + service.execute(protected_branch) + end.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + end +end diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb index 9fa5983db66..3f6f8e09565 100644 --- a/spec/services/protected_branches/update_service_spec.rb +++ b/spec/services/protected_branches/update_service_spec.rb @@ -22,5 +22,16 @@ describe ProtectedBranches::UpdateService do expect { service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError) end end + + context 'when a policy restricts rule creation' do + before do + policy = instance_double(ProtectedBranchPolicy, can?: false) + expect(ProtectedBranchPolicy).to receive(:new).and_return(policy) + end + + it "prevents creation of the protected branch rule" do + expect { service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end end end diff --git a/spec/services/protected_tags/destroy_service_spec.rb b/spec/services/protected_tags/destroy_service_spec.rb new file mode 100644 index 00000000000..e12f53a2221 --- /dev/null +++ b/spec/services/protected_tags/destroy_service_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe ProtectedTags::DestroyService do + let(:protected_tag) { create(:protected_tag) } + let(:project) { protected_tag.project } + let(:user) { project.owner } + + describe '#execute' do + subject(:service) { described_class.new(project, user) } + + it 'destroy a protected tag' do + service.execute(protected_tag) + + expect(protected_tag).to be_destroyed + end + end +end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index a3893188c6e..e28b0ea5cf2 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -743,7 +743,7 @@ describe SystemNoteService do expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.") end - it "blocks cross reference when #{type.underscore}_events is true" do + it "creates cross reference when #{type.underscore}_events is true" do jira_tracker.update("#{type}_events" => true) expect(cross_reference(type)).to eq(success_message) diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb index 576db1dde2d..d974cc0226f 100644 --- a/spec/services/verify_pages_domain_service_spec.rb +++ b/spec/services/verify_pages_domain_service_spec.rb @@ -93,6 +93,25 @@ describe VerifyPagesDomainService do expect(domain).not_to be_enabled end end + + context 'invalid domain' do + let(:domain) { build(:pages_domain, :expired, :with_missing_chain) } + + before do + domain.save(validate: false) + end + + it 'can be disabled' do + error_status[:message] += '. It is now disabled.' + + stub_resolver + + expect(service.execute).to eq(error_status) + + expect(domain).not_to be_verified + expect(domain).not_to be_enabled + end + end end context 'timeout behaviour' do diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 21910e69d2e..2ef2e61babc 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -14,6 +14,20 @@ describe WebHookService do end let(:service_instance) { described_class.new(project_hook, data, :push_hooks) } + describe '#initialize' do + it 'allow_local_requests is true if hook is a SystemHook' do + instance = described_class.new(build(:system_hook), data, :system_hook) + expect(instance.request_options[:allow_local_requests]).to be_truthy + end + + it 'allow_local_requests is false if hook is not a SystemHook' do + %i(project_hook service_hook web_hook_log).each do |hook| + instance = described_class.new(build(hook), data, hook) + expect(instance.request_options[:allow_local_requests]).to be_falsey + end + end + end + describe '#execute' do before do project.hooks << [project_hook] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9f6f0204a16..e8cecf361ff 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -97,6 +97,10 @@ RSpec.configure do |config| TestEnv.init end + config.after(:all) do + TestEnv.clean_test_path + end + config.before(:example) do # Skip pre-receive hook check so we can use the web editor and merge. allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([true, nil]) @@ -197,6 +201,22 @@ RSpec.configure do |config| Ability.allowed?(*args) end end + + config.before(:each, :http_pages_enabled) do |_| + allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) + end + + config.before(:each, :https_pages_enabled) do |_| + allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443']) + end + + config.before(:each, :http_pages_disabled) do |_| + allow(Gitlab.config.pages).to receive(:external_http).and_return(false) + end + + config.before(:each, :https_pages_disabled) do |_| + allow(Gitlab.config.pages).to receive(:external_https).and_return(false) + end end # add simpler way to match asset paths containing digest strings diff --git a/spec/support/gitaly.rb b/spec/support/gitaly.rb index c7e8a39a617..9cf541372b5 100644 --- a/spec/support/gitaly.rb +++ b/spec/support/gitaly.rb @@ -1,11 +1,13 @@ RSpec.configure do |config| config.before(:each) do |example| if example.metadata[:disable_gitaly] - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(false) + # Use 'and_wrap_original' to make sure the arguments are valid + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_wrap_original { |m, *args| m.call(*args) && false } else next if example.metadata[:skip_gitaly_mock] - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true) + # Use 'and_wrap_original' to make sure the arguments are valid + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_wrap_original { |m, *args| m.call(*args) || true } end end end diff --git a/spec/support/http_io/http_io_helpers.rb b/spec/support/http_io/http_io_helpers.rb new file mode 100644 index 00000000000..31e07e720cd --- /dev/null +++ b/spec/support/http_io/http_io_helpers.rb @@ -0,0 +1,64 @@ +module HttpIOHelpers + def stub_remote_trace_206 + WebMock.stub_request(:get, remote_trace_url) + .to_return { |request| remote_trace_response(request, 206) } + end + + def stub_remote_trace_200 + WebMock.stub_request(:get, remote_trace_url) + .to_return { |request| remote_trace_response(request, 200) } + end + + def stub_remote_trace_500 + WebMock.stub_request(:get, remote_trace_url) + .to_return(status: [500, "Internal Server Error"]) + end + + def remote_trace_url + "http://trace.com/trace" + end + + def remote_trace_response(request, responce_status) + range = request.headers['Range'].match(/bytes=(\d+)-(\d+)/) + + { + status: responce_status, + headers: remote_trace_response_headers(responce_status, range[1].to_i, range[2].to_i), + body: range_trace_body(range[1].to_i, range[2].to_i) + } + end + + def remote_trace_response_headers(responce_status, from, to) + headers = { 'Content-Type' => 'text/plain' } + + if responce_status == 206 + headers.merge('Content-Range' => "bytes #{from}-#{to}/#{remote_trace_size}") + end + + headers + end + + def range_trace_body(from, to) + remote_trace_body[from..to] + end + + def remote_trace_body + @remote_trace_body ||= File.read(expand_fixture_path('trace/sample_trace')) + end + + def remote_trace_size + remote_trace_body.length + end + + def set_smaller_buffer_size_than(file_size) + blocks = (file_size / 128) + new_size = (blocks / 2) * 128 + stub_const("Gitlab::Ci::Trace::HttpIO::BUFFER_SIZE", new_size) + end + + def set_larger_buffer_size_than(file_size) + blocks = (file_size / 128) + new_size = (blocks * 2) * 128 + stub_const("Gitlab::Ci::Trace::HttpIO::BUFFER_SIZE", new_size) + end +end diff --git a/spec/support/shared_examples/controllers/variables_shared_examples.rb b/spec/support/shared_examples/controllers/variables_shared_examples.rb index d7acf8c0032..b615a8f54cf 100644 --- a/spec/support/shared_examples/controllers/variables_shared_examples.rb +++ b/spec/support/shared_examples/controllers/variables_shared_examples.rb @@ -16,19 +16,19 @@ shared_examples 'PATCH #update updates variables' do let(:variable_attributes) do { id: variable.id, key: variable.key, - value: variable.value, + secret_value: variable.value, protected: variable.protected?.to_s } end let(:new_variable_attributes) do { key: 'new_key', - value: 'dummy_value', + secret_value: 'dummy_value', protected: 'false' } end context 'with invalid new variable parameters' do let(:variables_attributes) do [ - variable_attributes.merge(value: 'other_value'), + variable_attributes.merge(secret_value: 'other_value'), new_variable_attributes.merge(key: '...?') ] end @@ -52,7 +52,7 @@ shared_examples 'PATCH #update updates variables' do let(:variables_attributes) do [ new_variable_attributes, - new_variable_attributes.merge(value: 'other_value') + new_variable_attributes.merge(secret_value: 'other_value') ] end @@ -74,7 +74,7 @@ shared_examples 'PATCH #update updates variables' do context 'with valid new variable parameters' do let(:variables_attributes) do [ - variable_attributes.merge(value: 'other_value'), + variable_attributes.merge(secret_value: 'other_value'), new_variable_attributes ] end diff --git a/spec/support/shared_examples/models/atomic_internal_id_spec.rb b/spec/support/shared_examples/models/atomic_internal_id_spec.rb new file mode 100644 index 00000000000..144af4fc475 --- /dev/null +++ b/spec/support/shared_examples/models/atomic_internal_id_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +shared_examples_for 'AtomicInternalId' do + describe '.has_internal_id' do + describe 'Module inclusion' do + subject { described_class } + + it { is_expected.to include_module(AtomicInternalId) } + end + + describe 'Validation' do + subject { instance } + + before do + allow(InternalId).to receive(:generate_next).and_return(nil) + end + + it { is_expected.to validate_presence_of(internal_id_attribute) } + it { is_expected.to validate_numericality_of(internal_id_attribute) } + end + + describe 'internal id generation' do + subject { instance.save! } + + it 'calls InternalId.generate_next and sets internal id attribute' do + iid = rand(1..1000) + + expect(InternalId).to receive(:generate_next).with(instance, scope_attrs, usage, any_args).and_return(iid) + subject + expect(instance.public_send(internal_id_attribute)).to eq(iid) + end + + it 'does not overwrite an existing internal id' do + instance.public_send("#{internal_id_attribute}=", 4711) + + expect { subject }.not_to change { instance.public_send(internal_id_attribute) } + end + end + end +end diff --git a/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb new file mode 100644 index 00000000000..cd9974cd6e2 --- /dev/null +++ b/spec/support/shared_examples/uploaders/object_storage_shared_examples.rb @@ -0,0 +1,126 @@ +shared_context 'with storage' do |store, **stub_params| + before do + subject.object_store = store + end +end + +shared_examples "migrates" do |to_store:, from_store: nil| + let(:to) { to_store } + let(:from) { from_store || subject.object_store } + + def migrate(to) + subject.migrate!(to) + end + + def checksum + Digest::SHA256.hexdigest(subject.read) + end + + before do + migrate(from) + end + + it 'returns corresponding file type' do + expect(subject).to be_an(CarrierWave::Uploader::Base) + expect(subject).to be_a(ObjectStorage::Concern) + + if from == described_class::Store::REMOTE + expect(subject.file).to be_a(CarrierWave::Storage::Fog::File) + elsif from == described_class::Store::LOCAL + expect(subject.file).to be_a(CarrierWave::SanitizedFile) + else + raise 'Unexpected file type' + end + end + + it 'does nothing when migrating to the current store' do + expect { migrate(from) }.not_to change { subject.object_store }.from(from) + end + + it 'migrate to the specified store' do + from_checksum = checksum + + expect { migrate(to) }.to change { subject.object_store }.from(from).to(to) + expect(checksum).to eq(from_checksum) + end + + it 'removes the original file after the migration' do + original_file = subject.file.path + migrate(to) + + expect(File.exist?(original_file)).to be_falsey + end + + it 'can access to the original file during migration' do + file = subject.file + + allow(subject).to receive(:delete_migrated_file) { } # Remove as a callback of :migrate + allow(subject).to receive(:record_upload) { } # Remove as a callback of :store (:record_upload) + + expect(file.exists?).to be_truthy + expect { migrate(to) }.not_to change { file.exists? } + end + + context 'when migrate! is not oqqupied by another process' do + it 'executes migrate!' do + expect(subject).to receive(:object_store=).at_least(1) + + migrate(to) + end + end + + context 'when migrate! is occupied by another process' do + let(:exclusive_lease_key) { "object_storage_migrate:#{subject.model.class}:#{subject.model.id}" } + + before do + @uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain + end + + it 'does not execute migrate!' do + expect(subject).not_to receive(:unsafe_migrate!) + + expect { migrate(to) }.to raise_error('Already running') + end + + after do + Gitlab::ExclusiveLease.cancel(exclusive_lease_key, @uuid) + end + end + + context 'migration is unsuccessful' do + shared_examples "handles gracefully" do |error:| + it 'does not update the object_store' do + expect { migrate(to) }.to raise_error(error) + expect(subject.object_store).to eq(from) + end + + it 'does not delete the original file' do + expect { migrate(to) }.to raise_error(error) + expect(subject.exists?).to be_truthy + end + end + + context 'when the store is not supported' do + let(:to) { -1 } # not a valid store + + include_examples "handles gracefully", error: ObjectStorage::UnknownStoreError + end + + context 'upon a fog failure' do + before do + storage_class = subject.send(:storage_for, to).class + expect_any_instance_of(storage_class).to receive(:store!).and_raise("Store failure.") + end + + include_examples "handles gracefully", error: "Store failure." + end + + context 'upon a database failure' do + before do + expect(uploader).to receive(:persist_object_store!).and_raise("ActiveRecord failure.") + end + + include_examples "handles gracefully", error: "ActiveRecord failure." + end + end +end diff --git a/spec/support/stored_repositories.rb b/spec/support/stored_repositories.rb index 52e47ae2d34..21995c89a6e 100644 --- a/spec/support/stored_repositories.rb +++ b/spec/support/stored_repositories.rb @@ -4,7 +4,7 @@ RSpec.configure do |config| end config.before(:all, :broken_storage) do - FileUtils.rm_rf Gitlab.config.repositories.storages.broken['path'] + FileUtils.rm_rf Gitlab.config.repositories.storages.broken.legacy_disk_path end config.before(:each, :broken_storage) do diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb index 9f08c139322..bad1d34df3a 100644 --- a/spec/support/stub_configuration.rb +++ b/spec/support/stub_configuration.rb @@ -50,8 +50,12 @@ module StubConfiguration # Default storage is always required messages['default'] ||= Gitlab.config.repositories.storages.default - messages.each do |storage_name, storage_settings| - storage_settings['path'] = TestEnv.repos_path unless storage_settings.key?('path') + messages.each do |storage_name, storage_hash| + if !storage_hash.key?('path') || storage_hash['path'] == Gitlab::GitalyClient::StorageSettings::Deprecated + storage_hash['path'] = TestEnv.repos_path + end + + messages[storage_name] = Gitlab::GitalyClient::StorageSettings.new(storage_hash.to_h) end allow(Gitlab.config.repositories).to receive(:storages).and_return(Settingslogic.new(messages)) diff --git a/spec/support/stub_object_storage.rb b/spec/support/stub_object_storage.rb new file mode 100644 index 00000000000..6e88641da42 --- /dev/null +++ b/spec/support/stub_object_storage.rb @@ -0,0 +1,48 @@ +module StubConfiguration + def stub_object_storage_uploader( + config:, + uploader:, + remote_directory:, + enabled: true, + proxy_download: false, + background_upload: false, + direct_upload: false + ) + allow(config).to receive(:enabled) { enabled } + allow(config).to receive(:proxy_download) { proxy_download } + allow(config).to receive(:background_upload) { background_upload } + allow(config).to receive(:direct_upload) { direct_upload } + + return unless enabled + + Fog.mock! + + ::Fog::Storage.new(uploader.object_store_credentials).tap do |connection| + begin + connection.directories.create(key: remote_directory) + rescue Excon::Error::Conflict + end + end + end + + def stub_artifacts_object_storage(**params) + stub_object_storage_uploader(config: Gitlab.config.artifacts.object_store, + uploader: JobArtifactUploader, + remote_directory: 'artifacts', + **params) + end + + def stub_lfs_object_storage(**params) + stub_object_storage_uploader(config: Gitlab.config.lfs.object_store, + uploader: LfsObjectUploader, + remote_directory: 'lfs-objects', + **params) + end + + def stub_uploads_object_storage(uploader = described_class, **params) + stub_object_storage_uploader(config: Gitlab.config.uploads.object_store, + uploader: uploader, + remote_directory: 'uploads', + **params) + end +end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 01321989f01..f14e69b1041 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -225,7 +225,7 @@ module TestEnv end def repos_path - Gitlab.config.repositories.storages.default['path'] + Gitlab.config.repositories.storages.default.legacy_disk_path end def backup_path diff --git a/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb new file mode 100644 index 00000000000..8544fb62b5a --- /dev/null +++ b/spec/tasks/gitlab/artifacts/migrate_rake_spec.rb @@ -0,0 +1,118 @@ +require 'rake_helper' + +describe 'gitlab:artifacts namespace rake task' do + before(:context) do + Rake.application.rake_require 'tasks/gitlab/artifacts/migrate' + end + + let(:object_storage_enabled) { false } + + before do + stub_artifacts_object_storage(enabled: object_storage_enabled) + end + + subject { run_rake_task('gitlab:artifacts:migrate') } + + context 'legacy artifacts' do + describe 'migrate' do + let!(:build) { create(:ci_build, :legacy_artifacts, artifacts_file_store: store, artifacts_metadata_store: store) } + + context 'when local storage is used' do + let(:store) { ObjectStorage::Store::LOCAL } + + context 'and job does not have file store defined' do + let(:object_storage_enabled) { true } + let(:store) { nil } + + it "migrates file to remote storage" do + subject + + expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::REMOTE) + expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'and remote storage is defined' do + let(:object_storage_enabled) { true } + + it "migrates file to remote storage" do + subject + + expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::REMOTE) + expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'and remote storage is not defined' do + it "fails to migrate to remote storage" do + subject + + expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::LOCAL) + expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::LOCAL) + end + end + end + + context 'when remote storage is used' do + let(:object_storage_enabled) { true } + + let(:store) { ObjectStorage::Store::REMOTE } + + it "file stays on remote storage" do + subject + + expect(build.reload.artifacts_file_store).to eq(ObjectStorage::Store::REMOTE) + expect(build.reload.artifacts_metadata_store).to eq(ObjectStorage::Store::REMOTE) + end + end + end + end + + context 'job artifacts' do + let!(:artifact) { create(:ci_job_artifact, :archive, file_store: store) } + + context 'when local storage is used' do + let(:store) { ObjectStorage::Store::LOCAL } + + context 'and job does not have file store defined' do + let(:object_storage_enabled) { true } + let(:store) { nil } + + it "migrates file to remote storage" do + subject + + expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'and remote storage is defined' do + let(:object_storage_enabled) { true } + + it "migrates file to remote storage" do + subject + + expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'and remote storage is not defined' do + it "fails to migrate to remote storage" do + subject + + expect(artifact.reload.file_store).to eq(ObjectStorage::Store::LOCAL) + end + end + end + + context 'when remote storage is used' do + let(:object_storage_enabled) { true } + let(:store) { ObjectStorage::Store::REMOTE } + + it "file stays on remote storage" do + subject + + expect(artifact.reload.file_store).to eq(ObjectStorage::Store::REMOTE) + end + end + end +end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index 168facd51a6..0d24782f317 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -195,14 +195,23 @@ describe 'gitlab:app namespace rake task' do end context 'multiple repository storages' do - let(:gitaly_address) { Gitlab.config.repositories.storages.default.gitaly_address } + let(:storage_default) do + Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage')) + end + let(:test_second_storage) do + Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/custom_storage')) + end let(:storages) do { - 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage'), 'gitaly_address' => gitaly_address }, - 'test_second_storage' => { 'path' => Settings.absolute('tmp/tests/custom_storage'), 'gitaly_address' => gitaly_address } + 'default' => storage_default, + 'test_second_storage' => test_second_storage } end + before(:all) do + @default_storage_hash = Gitlab.config.repositories.storages.default.to_h + end + before do # We only need a backup of the repositories for this test stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry') diff --git a/spec/tasks/gitlab/cleanup_rake_spec.rb b/spec/tasks/gitlab/cleanup_rake_spec.rb index 9e746ceddd6..2bf873c923f 100644 --- a/spec/tasks/gitlab/cleanup_rake_spec.rb +++ b/spec/tasks/gitlab/cleanup_rake_spec.rb @@ -6,13 +6,16 @@ describe 'gitlab:cleanup rake tasks' do end describe 'cleanup' do - let(:gitaly_address) { Gitlab.config.repositories.storages.default.gitaly_address } let(:storages) do { - 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage'), 'gitaly_address' => gitaly_address } + 'default' => Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage')) } end + before(:all) do + @default_storage_hash = Gitlab.config.repositories.storages.default.to_h + end + before do FileUtils.mkdir(Settings.absolute('tmp/tests/default_storage')) allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) diff --git a/spec/tasks/gitlab/git_rake_spec.rb b/spec/tasks/gitlab/git_rake_spec.rb index 9aebf7b0b4a..1efaecc63a5 100644 --- a/spec/tasks/gitlab/git_rake_spec.rb +++ b/spec/tasks/gitlab/git_rake_spec.rb @@ -1,10 +1,13 @@ require 'rake_helper' describe 'gitlab:git rake tasks' do + before(:all) do + @default_storage_hash = Gitlab.config.repositories.storages.default.to_h + end + before do Rake.application.rake_require 'tasks/gitlab/git' - - storages = { 'default' => { 'path' => Settings.absolute('tmp/tests/default_storage') } } + storages = { 'default' => Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage')) } FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/1/2/test.git')) allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index 1f4053ff9ad..1e507c0236e 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -99,14 +99,14 @@ describe 'gitlab:gitaly namespace rake task' do describe 'storage_config' do it 'prints storage configuration in a TOML format' do config = { - 'default' => { + 'default' => Gitlab::GitalyClient::StorageSettings.new( 'path' => '/path/to/default', 'gitaly_address' => 'unix:/path/to/my.socket' - }, - 'nfs_01' => { + ), + 'nfs_01' => Gitlab::GitalyClient::StorageSettings.new( 'path' => '/path/to/nfs_01', 'gitaly_address' => 'unix:/path/to/my.socket' - } + ) } allow(Gitlab.config.repositories).to receive(:storages).and_return(config) allow(Rails.env).to receive(:test?).and_return(false) @@ -134,7 +134,7 @@ describe 'gitlab:gitaly namespace rake task' do parsed_output = TomlRB.parse(expected_output) config.each do |name, params| - expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params['path'] }) + expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params.legacy_disk_path }) end end end diff --git a/spec/tasks/gitlab/lfs/migrate_rake_spec.rb b/spec/tasks/gitlab/lfs/migrate_rake_spec.rb new file mode 100644 index 00000000000..66d1a192a96 --- /dev/null +++ b/spec/tasks/gitlab/lfs/migrate_rake_spec.rb @@ -0,0 +1,37 @@ +require 'rake_helper' + +describe 'gitlab:lfs namespace rake task' do + before :all do + Rake.application.rake_require 'tasks/gitlab/lfs/migrate' + end + + describe 'migrate' do + let(:local) { ObjectStorage::Store::LOCAL } + let(:remote) { ObjectStorage::Store::REMOTE } + let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) } + + def lfs_migrate + run_rake_task('gitlab:lfs:migrate') + end + + context 'object storage disabled' do + before do + stub_lfs_object_storage(enabled: false) + end + + it "doesn't migrate files" do + expect { lfs_migrate }.not_to change { lfs_object.reload.file_store } + end + end + + context 'object storage enabled' do + before do + stub_lfs_object_storage + end + + it 'migrates local file to object storage' do + expect { lfs_migrate }.to change { lfs_object.reload.file_store }.from(local).to(remote) + end + end + end +end diff --git a/spec/tasks/gitlab/shell_rake_spec.rb b/spec/tasks/gitlab/shell_rake_spec.rb index 65155cb044d..4a756c5742d 100644 --- a/spec/tasks/gitlab/shell_rake_spec.rb +++ b/spec/tasks/gitlab/shell_rake_spec.rb @@ -11,7 +11,7 @@ describe 'gitlab:shell rake tasks' do it 'invokes create_hooks task' do expect(Rake::Task['gitlab:shell:create_hooks']).to receive(:invoke) - storages = Gitlab.config.repositories.storages.values.map { |rs| rs['path'] } + storages = Gitlab.config.repositories.storages.values.map(&:legacy_disk_path) expect(Kernel).to receive(:system).with('bin/install', *storages).and_call_original expect(Kernel).to receive(:system).with('bin/compile').and_call_original diff --git a/spec/tasks/gitlab/uploads/migrate_rake_spec.rb b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb new file mode 100644 index 00000000000..b778d26060d --- /dev/null +++ b/spec/tasks/gitlab/uploads/migrate_rake_spec.rb @@ -0,0 +1,28 @@ +require 'rake_helper' + +describe 'gitlab:uploads:migrate rake tasks' do + let!(:projects) { create_list(:project, 10, :with_avatar) } + let(:model_class) { Project } + let(:uploader_class) { AvatarUploader } + let(:mounted_as) { :avatar } + let(:batch_size) { 3 } + + before do + stub_env('BATCH', batch_size.to_s) + stub_uploads_object_storage(uploader_class) + Rake.application.rake_require 'tasks/gitlab/uploads/migrate' + + allow(ObjectStorage::MigrateUploadsWorker).to receive(:perform_async) + end + + def run + args = [uploader_class.to_s, model_class.to_s, mounted_as].compact + run_rake_task("gitlab:uploads:migrate", *args) + end + + it 'enqueue jobs in batch' do + expect(ObjectStorage::MigrateUploadsWorker).to receive(:enqueue!).exactly(4).times + + run + end +end diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb index 091ba824fc6..d302c14efb9 100644 --- a/spec/uploaders/attachment_uploader_spec.rb +++ b/spec/uploaders/attachment_uploader_spec.rb @@ -11,4 +11,26 @@ describe AttachmentUploader do store_dir: %r[uploads/-/system/note/attachment/], upload_path: %r[uploads/-/system/note/attachment/], absolute_path: %r[#{CarrierWave.root}/uploads/-/system/note/attachment/] + + context "object_store is REMOTE" do + before do + stub_uploads_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like 'builds correct paths', + store_dir: %r[note/attachment/], + upload_path: %r[note/attachment/] + end + + describe "#migrate!" do + before do + uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/doc_sample.txt'))) + stub_uploads_object_storage + end + + it_behaves_like "migrates", to_store: described_class::Store::REMOTE + it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL + end end diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb index bf9028c9260..b0468bc35ff 100644 --- a/spec/uploaders/avatar_uploader_spec.rb +++ b/spec/uploaders/avatar_uploader_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe AvatarUploader do - let(:model) { create(:user, :with_avatar) } + let(:model) { build_stubbed(:user) } let(:uploader) { described_class.new(model, :avatar) } let(:upload) { create(:upload, model: model) } @@ -12,15 +12,28 @@ describe AvatarUploader do upload_path: %r[uploads/-/system/user/avatar/], absolute_path: %r[#{CarrierWave.root}/uploads/-/system/user/avatar/] - describe '#move_to_cache' do - it 'is false' do - expect(uploader.move_to_cache).to eq(false) + context "object_store is REMOTE" do + before do + stub_uploads_object_storage end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like 'builds correct paths', + store_dir: %r[user/avatar/], + upload_path: %r[user/avatar/] end - describe '#move_to_store' do - it 'is false' do - expect(uploader.move_to_store).to eq(false) + context "with a file" do + let(:project) { create(:project, :with_avatar) } + let(:uploader) { project.avatar } + let(:upload) { uploader.upload } + + before do + stub_uploads_object_storage end + + it_behaves_like "migrates", to_store: described_class::Store::REMOTE + it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL end end diff --git a/spec/uploaders/file_mover_spec.rb b/spec/uploaders/file_mover_spec.rb index bc024cd307c..68b7e24776d 100644 --- a/spec/uploaders/file_mover_spec.rb +++ b/spec/uploaders/file_mover_spec.rb @@ -36,6 +36,12 @@ describe FileMover do it 'creates a new update record' do expect { subject }.to change { Upload.count }.by(1) end + + it 'schedules a background migration' do + expect_any_instance_of(PersonalFileUploader).to receive(:schedule_background_upload).once + + subject + end end context 'when update_markdown fails' do diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb index b42ce982b27..db2810bbe1d 100644 --- a/spec/uploaders/file_uploader_spec.rb +++ b/spec/uploaders/file_uploader_spec.rb @@ -11,32 +11,41 @@ describe FileUploader do shared_examples 'builds correct legacy storage paths' do include_examples 'builds correct paths', store_dir: %r{awesome/project/\h+}, + upload_path: %r{\h+/<filename>}, absolute_path: %r{#{described_class.root}/awesome/project/secret/foo.jpg} end - shared_examples 'uses hashed storage' do - context 'when rolled out attachments' do - let(:project) { build_stubbed(:project, namespace: group, name: 'project') } + context 'legacy storage' do + it_behaves_like 'builds correct legacy storage paths' - before do - allow(project).to receive(:disk_path).and_return('ca/fe/fe/ed') - end + context 'uses hashed storage' do + context 'when rolled out attachments' do + let(:project) { build_stubbed(:project, namespace: group, name: 'project') } - it_behaves_like 'builds correct paths', - store_dir: %r{ca/fe/fe/ed/\h+}, - absolute_path: %r{#{described_class.root}/ca/fe/fe/ed/secret/foo.jpg} - end + include_examples 'builds correct paths', + store_dir: %r{@hashed/\h{2}/\h{2}/\h+}, + upload_path: %r{\h+/<filename>} + end - context 'when only repositories are rolled out' do - let(:project) { build_stubbed(:project, namespace: group, name: 'project', storage_version: Project::HASHED_STORAGE_FEATURES[:repository]) } + context 'when only repositories are rolled out' do + let(:project) { build_stubbed(:project, namespace: group, name: 'project', storage_version: Project::HASHED_STORAGE_FEATURES[:repository]) } - it_behaves_like 'builds correct legacy storage paths' + it_behaves_like 'builds correct legacy storage paths' + end end end - context 'legacy storage' do - it_behaves_like 'builds correct legacy storage paths' - include_examples 'uses hashed storage' + context 'object store is remote' do + before do + stub_uploads_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + # always use hashed storage path for remote uploads + it_behaves_like 'builds correct paths', + store_dir: %r{@hashed/\h{2}/\h{2}/\h+}, + upload_path: %r{@hashed/\h{2}/\h{2}/\h+/\h+/<filename>} end describe 'initialize' do @@ -78,6 +87,16 @@ describe FileUploader do end end + describe "#migrate!" do + before do + uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/dk.png'))) + stub_uploads_object_storage + end + + it_behaves_like "migrates", to_store: described_class::Store::REMOTE + it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL + end + describe '#upload=' do let(:secret) { SecureRandom.hex } let(:upload) { create(:upload, :issuable_upload, secret: secret, filename: 'file.txt') } @@ -93,15 +112,5 @@ describe FileUploader do uploader.upload = upload end - - context 'uploader_context is empty' do - it 'fallbacks to regex based extraction' do - expect(upload).to receive(:uploader_context).and_return({}) - - uploader.upload = upload - expect(uploader.secret).to eq(secret) - expect(uploader.instance_variable_get(:@identifier)).to eq('file.txt') - end - end end end diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb index 60e35dcf235..4fba122cce1 100644 --- a/spec/uploaders/gitlab_uploader_spec.rb +++ b/spec/uploaders/gitlab_uploader_spec.rb @@ -27,7 +27,7 @@ describe GitlabUploader do describe '#file_cache_storage?' do context 'when file storage is used' do before do - uploader_class.cache_storage(:file) + expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::File } end it { is_expected.to be_file_cache_storage } @@ -35,7 +35,7 @@ describe GitlabUploader do context 'when is remote storage' do before do - uploader_class.cache_storage(:fog) + expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::Fog } end it { is_expected.not_to be_file_cache_storage } diff --git a/spec/uploaders/job_artifact_uploader_spec.rb b/spec/uploaders/job_artifact_uploader_spec.rb index 5612ec7e661..42036d67f3d 100644 --- a/spec/uploaders/job_artifact_uploader_spec.rb +++ b/spec/uploaders/job_artifact_uploader_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe JobArtifactUploader do - let(:job_artifact) { create(:ci_job_artifact) } + let(:store) { described_class::Store::LOCAL } + let(:job_artifact) { create(:ci_job_artifact, file_store: store) } let(:uploader) { described_class.new(job_artifact, :file) } subject { uploader } @@ -11,6 +12,17 @@ describe JobArtifactUploader do cache_dir: %r[artifacts/tmp/cache], work_dir: %r[artifacts/tmp/work] + context "object store is REMOTE" do + before do + stub_artifacts_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like "builds correct paths", + store_dir: %r[\h{2}/\h{2}/\h{64}/\d{4}_\d{1,2}_\d{1,2}/\d+/\d+\z] + end + describe '#open' do subject { uploader.open } @@ -36,6 +48,17 @@ describe JobArtifactUploader do end end end + + context 'when trace is stored in Object storage' do + before do + allow(uploader).to receive(:file_storage?) { false } + allow(uploader).to receive(:url) { 'http://object_storage.com/trace' } + end + + it 'returns http io stream' do + is_expected.to be_a(Gitlab::Ci::Trace::HttpIO) + end + end end context 'file is stored in valid local_path' do @@ -55,4 +78,14 @@ describe JobArtifactUploader do it { is_expected.to include("/#{job_artifact.job_id}/#{job_artifact.id}/") } it { is_expected.to end_with("ci_build_artifacts.zip") } end + + describe "#migrate!" do + before do + uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/trace/sample_trace'))) + stub_artifacts_object_storage + end + + it_behaves_like "migrates", to_store: described_class::Store::REMOTE + it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL + end end diff --git a/spec/uploaders/legacy_artifact_uploader_spec.rb b/spec/uploaders/legacy_artifact_uploader_spec.rb index 54c6a8b869b..eeb6fd90c9d 100644 --- a/spec/uploaders/legacy_artifact_uploader_spec.rb +++ b/spec/uploaders/legacy_artifact_uploader_spec.rb @@ -1,7 +1,8 @@ require 'rails_helper' describe LegacyArtifactUploader do - let(:job) { create(:ci_build) } + let(:store) { described_class::Store::LOCAL } + let(:job) { create(:ci_build, artifacts_file_store: store) } let(:uploader) { described_class.new(job, :legacy_artifacts_file) } let(:local_path) { described_class.root } @@ -20,6 +21,17 @@ describe LegacyArtifactUploader do cache_dir: %r[artifacts/tmp/cache], work_dir: %r[artifacts/tmp/work] + context 'object store is remote' do + before do + stub_artifacts_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like "builds correct paths", + store_dir: %r[\d{4}_\d{1,2}/\d+/\d+\z] + end + describe '#filename' do # we need to use uploader, as this makes to use mounter # which initialises uploader.file object diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb index 6ebc885daa8..a2fb3886610 100644 --- a/spec/uploaders/lfs_object_uploader_spec.rb +++ b/spec/uploaders/lfs_object_uploader_spec.rb @@ -11,4 +11,62 @@ describe LfsObjectUploader do store_dir: %r[\h{2}/\h{2}], cache_dir: %r[/lfs-objects/tmp/cache], work_dir: %r[/lfs-objects/tmp/work] + + context "object store is REMOTE" do + before do + stub_lfs_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like "builds correct paths", + store_dir: %r[\h{2}/\h{2}] + end + + describe 'migration to object storage' do + context 'with object storage disabled' do + it "is skipped" do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + lfs_object + end + end + + context 'with object storage enabled' do + before do + stub_lfs_object_storage(background_upload: true) + end + + it 'is scheduled to run after creation' do + expect(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async).with(described_class.name, 'LfsObject', :file, kind_of(Numeric)) + + lfs_object + end + end + end + + describe 'remote file' do + let(:remote) { described_class::Store::REMOTE } + let(:lfs_object) { create(:lfs_object, file_store: remote) } + + context 'with object storage enabled' do + before do + stub_lfs_object_storage + end + + it 'can store file remotely' do + allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async) + + store_file(lfs_object) + + expect(lfs_object.file_store).to eq remote + expect(lfs_object.file.path).not_to be_blank + end + end + end + + def store_file(lfs_object) + lfs_object.file = fixture_file_upload(Rails.root.join("spec/fixtures/dk.png"), "`/png") + lfs_object.save! + end end diff --git a/spec/uploaders/namespace_file_uploader_spec.rb b/spec/uploaders/namespace_file_uploader_spec.rb index 24a2fc0f72e..a8ba01d70b8 100644 --- a/spec/uploaders/namespace_file_uploader_spec.rb +++ b/spec/uploaders/namespace_file_uploader_spec.rb @@ -13,4 +13,26 @@ describe NamespaceFileUploader do store_dir: %r[uploads/-/system/namespace/\d+], upload_path: IDENTIFIER, absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{IDENTIFIER}] + + context "object_store is REMOTE" do + before do + stub_uploads_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like 'builds correct paths', + store_dir: %r[namespace/\d+/\h+], + upload_path: IDENTIFIER + end + + describe "#migrate!" do + before do + uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/doc_sample.txt'))) + stub_uploads_object_storage + end + + it_behaves_like "migrates", to_store: described_class::Store::REMOTE + it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL + end end diff --git a/spec/uploaders/object_storage_spec.rb b/spec/uploaders/object_storage_spec.rb new file mode 100644 index 00000000000..1d406c71955 --- /dev/null +++ b/spec/uploaders/object_storage_spec.rb @@ -0,0 +1,630 @@ +require 'rails_helper' +require 'carrierwave/storage/fog' + +class Implementation < GitlabUploader + include ObjectStorage::Concern + include ::RecordsUploads::Concern + prepend ::ObjectStorage::Extension::RecordsUploads + + storage_options Gitlab.config.uploads + + private + + # user/:id + def dynamic_segment + File.join(model.class.to_s.underscore, model.id.to_s) + end +end + +describe ObjectStorage do + let(:uploader_class) { Implementation } + let(:object) { build_stubbed(:user) } + let(:uploader) { uploader_class.new(object, :file) } + + describe '#object_store=' do + before do + allow(uploader_class).to receive(:object_store_enabled?).and_return(true) + end + + it "reload the local storage" do + uploader.object_store = described_class::Store::LOCAL + expect(uploader.file_storage?).to be_truthy + end + + it "reload the REMOTE storage" do + uploader.object_store = described_class::Store::REMOTE + expect(uploader.file_storage?).to be_falsey + end + + context 'object_store is Store::LOCAL' do + before do + uploader.object_store = described_class::Store::LOCAL + end + + describe '#store_dir' do + it 'is the composition of (base_dir, dynamic_segment)' do + expect(uploader.store_dir).to start_with("uploads/-/system/user/") + end + end + end + + context 'object_store is Store::REMOTE' do + before do + uploader.object_store = described_class::Store::REMOTE + end + + describe '#store_dir' do + it 'is the composition of (dynamic_segment)' do + expect(uploader.store_dir).to start_with("user/") + end + end + end + end + + describe '#object_store' do + it "delegates to <mount>_store on model" do + expect(object).to receive(:file_store) + + uploader.object_store + end + + context 'when store is null' do + before do + expect(object).to receive(:file_store).and_return(nil) + end + + it "returns Store::LOCAL" do + expect(uploader.object_store).to eq(described_class::Store::LOCAL) + end + end + + context 'when value is set' do + before do + expect(object).to receive(:file_store).and_return(described_class::Store::REMOTE) + end + + it "returns the given value" do + expect(uploader.object_store).to eq(described_class::Store::REMOTE) + end + end + end + + describe '#file_cache_storage?' do + context 'when file storage is used' do + before do + expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::File } + end + + it { expect(uploader).to be_file_cache_storage } + end + + context 'when is remote storage' do + before do + expect(uploader_class).to receive(:cache_storage) { CarrierWave::Storage::Fog } + end + + it { expect(uploader).not_to be_file_cache_storage } + end + end + + # this means the model shall include + # include RecordsUpload::Concern + # prepend ObjectStorage::Extension::RecordsUploads + # the object_store persistence is delegated to the `Upload` model. + # + context 'when persist_object_store? is false' do + let(:object) { create(:project, :with_avatar) } + let(:uploader) { object.avatar } + + it { expect(object).to be_a(Avatarable) } + it { expect(uploader.persist_object_store?).to be_falsey } + + describe 'delegates the object_store logic to the `Upload` model' do + it 'sets @upload to the found `upload`' do + expect(uploader.upload).to eq(uploader.upload) + end + + it 'sets @object_store to the `Upload` value' do + expect(uploader.object_store).to eq(uploader.upload.store) + end + end + + describe '#migrate!' do + let(:new_store) { ObjectStorage::Store::REMOTE } + + before do + stub_uploads_object_storage(uploader: AvatarUploader) + end + + subject { uploader.migrate!(new_store) } + + it 'persist @object_store to the recorded upload' do + subject + + expect(uploader.upload.store).to eq(new_store) + end + + describe 'fails' do + it 'is handled gracefully' do + store = uploader.object_store + expect_any_instance_of(Upload).to receive(:save!).and_raise("An error") + + expect { subject }.to raise_error("An error") + expect(uploader.exists?).to be_truthy + expect(uploader.upload.store).to eq(store) + end + end + end + end + + # this means the model holds an <mounted_as>_store attribute directly + # and do not delegate the object_store persistence to the `Upload` model. + # + context 'persist_object_store? is true' do + context 'when using JobArtifactsUploader' do + let(:store) { described_class::Store::LOCAL } + let(:object) { create(:ci_job_artifact, :archive, file_store: store) } + let(:uploader) { object.file } + + context 'checking described_class' do + it "uploader include described_class::Concern" do + expect(uploader).to be_a(described_class::Concern) + end + end + + describe '#use_file' do + context 'when file is stored locally' do + it "calls a regular path" do + expect { |b| uploader.use_file(&b) }.not_to yield_with_args(%r[tmp/cache]) + end + end + + context 'when file is stored remotely' do + let(:store) { described_class::Store::REMOTE } + + before do + stub_artifacts_object_storage + end + + it "calls a cache path" do + expect { |b| uploader.use_file(&b) }.to yield_with_args(%r[tmp/cache]) + end + end + end + + describe '#migrate!' do + subject { uploader.migrate!(new_store) } + + shared_examples "updates the underlying <mounted>_store" do + it do + subject + + expect(object.file_store).to eq(new_store) + end + end + + context 'when using the same storage' do + let(:new_store) { store } + + it "to not migrate the storage" do + subject + + expect(uploader).not_to receive(:store!) + expect(uploader.object_store).to eq(store) + end + end + + context 'when migrating to local storage' do + let(:store) { described_class::Store::REMOTE } + let(:new_store) { described_class::Store::LOCAL } + + before do + stub_artifacts_object_storage + end + + include_examples "updates the underlying <mounted>_store" + + it "local file does not exist" do + expect(File.exist?(uploader.path)).to eq(false) + end + + it "remote file exist" do + expect(uploader.file.exists?).to be_truthy + end + + it "does migrate the file" do + subject + + expect(uploader.object_store).to eq(new_store) + expect(File.exist?(uploader.path)).to eq(true) + end + end + + context 'when migrating to remote storage' do + let(:new_store) { described_class::Store::REMOTE } + let!(:current_path) { uploader.path } + + it "file does exist" do + expect(File.exist?(current_path)).to eq(true) + end + + context 'when storage is disabled' do + before do + stub_artifacts_object_storage(enabled: false) + end + + it "to raise an error" do + expect { subject }.to raise_error(/Object Storage is not enabled/) + end + end + + context 'when credentials are set' do + before do + stub_artifacts_object_storage + end + + include_examples "updates the underlying <mounted>_store" + + it "does migrate the file" do + subject + + expect(uploader.object_store).to eq(new_store) + end + + it "does delete original file" do + subject + + expect(File.exist?(current_path)).to eq(false) + end + + context 'when subject save fails' do + before do + expect(uploader).to receive(:persist_object_store!).and_raise(RuntimeError, "exception") + end + + it "original file is not removed" do + expect { subject }.to raise_error(/exception/) + + expect(File.exist?(current_path)).to eq(true) + end + end + end + end + end + end + end + + describe '#fog_directory' do + let(:remote_directory) { 'directory' } + + before do + allow(uploader_class).to receive(:options) do + double(object_store: double(remote_directory: remote_directory)) + end + end + + subject { uploader.fog_directory } + + it { is_expected.to eq(remote_directory) } + end + + describe '#fog_credentials' do + let(:connection) { Settingslogic.new("provider" => "AWS") } + + before do + allow(uploader_class).to receive(:options) do + double(object_store: double(connection: connection)) + end + end + + subject { uploader.fog_credentials } + + it { is_expected.to eq(provider: 'AWS') } + end + + describe '#fog_public' do + subject { uploader.fog_public } + + it { is_expected.to eq(false) } + end + + describe '.workhorse_authorize' do + subject { uploader_class.workhorse_authorize } + + before do + # ensure that we use regular Fog libraries + # other tests might call `Fog.mock!` and + # it will make tests to fail + Fog.unmock! + end + + shared_examples 'uses local storage' do + it "returns temporary path" do + is_expected.to have_key(:TempPath) + + expect(subject[:TempPath]).to start_with(uploader_class.root) + expect(subject[:TempPath]).to include(described_class::TMP_UPLOAD_PATH) + end + + it "does not return remote store" do + is_expected.not_to have_key('RemoteObject') + end + end + + shared_examples 'uses remote storage' do + it "returns remote store" do + is_expected.to have_key(:RemoteObject) + + expect(subject[:RemoteObject]).to have_key(:ID) + expect(subject[:RemoteObject]).to have_key(:GetURL) + expect(subject[:RemoteObject]).to have_key(:DeleteURL) + expect(subject[:RemoteObject]).to have_key(:StoreURL) + expect(subject[:RemoteObject][:GetURL]).to include(described_class::TMP_UPLOAD_PATH) + expect(subject[:RemoteObject][:DeleteURL]).to include(described_class::TMP_UPLOAD_PATH) + expect(subject[:RemoteObject][:StoreURL]).to include(described_class::TMP_UPLOAD_PATH) + end + + it "does not return local store" do + is_expected.not_to have_key('TempPath') + end + end + + context 'when object storage is disabled' do + before do + allow(Gitlab.config.uploads.object_store).to receive(:enabled) { false } + end + + it_behaves_like 'uses local storage' + end + + context 'when object storage is enabled' do + before do + allow(Gitlab.config.uploads.object_store).to receive(:enabled) { true } + end + + context 'when direct upload is enabled' do + before do + allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { true } + end + + context 'uses AWS' do + before do + expect(uploader_class).to receive(:object_store_credentials) do + { provider: "AWS", + aws_access_key_id: "AWS_ACCESS_KEY_ID", + aws_secret_access_key: "AWS_SECRET_ACCESS_KEY", + region: "eu-central-1" } + end + end + + it_behaves_like 'uses remote storage' do + let(:storage_url) { "https://uploads.s3-eu-central-1.amazonaws.com/" } + + it 'returns links for S3' do + expect(subject[:RemoteObject][:GetURL]).to start_with(storage_url) + expect(subject[:RemoteObject][:DeleteURL]).to start_with(storage_url) + expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url) + end + end + end + + context 'uses Google' do + before do + expect(uploader_class).to receive(:object_store_credentials) do + { provider: "Google", + google_storage_access_key_id: 'ACCESS_KEY_ID', + google_storage_secret_access_key: 'SECRET_ACCESS_KEY' } + end + end + + it_behaves_like 'uses remote storage' do + let(:storage_url) { "https://storage.googleapis.com/uploads/" } + + it 'returns links for Google Cloud' do + expect(subject[:RemoteObject][:GetURL]).to start_with(storage_url) + expect(subject[:RemoteObject][:DeleteURL]).to start_with(storage_url) + expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url) + end + end + end + + context 'uses GDK/minio' do + before do + expect(uploader_class).to receive(:object_store_credentials) do + { provider: "AWS", + aws_access_key_id: "AWS_ACCESS_KEY_ID", + aws_secret_access_key: "AWS_SECRET_ACCESS_KEY", + endpoint: 'http://127.0.0.1:9000', + path_style: true, + region: "gdk" } + end + end + + it_behaves_like 'uses remote storage' do + let(:storage_url) { "http://127.0.0.1:9000/uploads/" } + + it 'returns links for S3' do + expect(subject[:RemoteObject][:GetURL]).to start_with(storage_url) + expect(subject[:RemoteObject][:DeleteURL]).to start_with(storage_url) + expect(subject[:RemoteObject][:StoreURL]).to start_with(storage_url) + end + end + end + end + + context 'when direct upload is disabled' do + before do + allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { false } + end + + it_behaves_like 'uses local storage' + end + end + end + + describe '#store_workhorse_file!' do + subject do + uploader.store_workhorse_file!(params, :file) + end + + context 'when local file is used' do + context 'when valid file is used' do + let(:target_path) do + File.join(uploader_class.root, uploader_class::TMP_UPLOAD_PATH) + end + + before do + FileUtils.mkdir_p(target_path) + end + + context 'when no filename is specified' do + let(:params) do + { "file.path" => "test/file" } + end + + it 'raises an error' do + expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Missing filename/) + end + end + + context 'when invalid file is specified' do + let(:file_path) do + File.join(target_path, "..", "test.file") + end + + before do + FileUtils.touch(file_path) + end + + let(:params) do + { "file.path" => file_path, + "file.name" => "my_file.txt" } + end + + it 'raises an error' do + expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Bad file path/) + end + end + + context 'when filename is specified' do + let(:params) do + { "file.path" => tmp_file, + "file.name" => "my_file.txt" } + end + + let(:tmp_file) { Tempfile.new('filename', target_path) } + + before do + FileUtils.touch(tmp_file) + end + + after do + FileUtils.rm_f(tmp_file) + end + + it 'succeeds' do + expect { subject }.not_to raise_error + + expect(uploader).to be_exists + end + + it 'proper path is being used' do + subject + + expect(uploader.path).to start_with(uploader_class.root) + expect(uploader.path).to end_with("my_file.txt") + end + + it 'source file to not exist' do + subject + + expect(File.exist?(tmp_file.path)).to be_falsey + end + end + end + end + + context 'when remote file is used' do + let!(:fog_connection) do + stub_uploads_object_storage(uploader_class) + end + + context 'when valid file is used' do + context 'when no filename is specified' do + let(:params) do + { "file.remote_id" => "test/123123" } + end + + it 'raises an error' do + expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Missing filename/) + end + end + + context 'when invalid file is specified' do + let(:params) do + { "file.remote_id" => "../test/123123", + "file.name" => "my_file.txt" } + end + + it 'raises an error' do + expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Bad file path/) + end + end + + context 'when non existing file is specified' do + let(:params) do + { "file.remote_id" => "test/12312300", + "file.name" => "my_file.txt" } + end + + it 'raises an error' do + expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Missing file/) + end + end + + context 'when filename is specified' do + let(:params) do + { "file.remote_id" => "test/123123", + "file.name" => "my_file.txt" } + end + + let!(:fog_file) do + fog_connection.directories.get('uploads').files.create( + key: 'tmp/upload/test/123123', + body: 'content' + ) + end + + it 'succeeds' do + expect { subject }.not_to raise_error + + expect(uploader).to be_exists + end + + it 'path to not be temporary' do + subject + + expect(uploader.path).not_to be_nil + expect(uploader.path).not_to include('tmp/upload') + expect(uploader.url).to include('/my_file.txt') + end + + it 'url is used' do + subject + + expect(uploader.url).not_to be_nil + expect(uploader.url).to include('/my_file.txt') + end + end + end + end + + context 'when no file is used' do + let(:params) { {} } + + it 'raises an error' do + expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Bad file/) + end + end + end +end diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb index ed1fba6edda..c70521d90dc 100644 --- a/spec/uploaders/personal_file_uploader_spec.rb +++ b/spec/uploaders/personal_file_uploader_spec.rb @@ -14,6 +14,18 @@ describe PersonalFileUploader do upload_path: IDENTIFIER, absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{IDENTIFIER}] + context "object_store is REMOTE" do + before do + stub_uploads_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like 'builds correct paths', + store_dir: %r[\d+/\h+], + upload_path: IDENTIFIER + end + describe '#to_h' do before do subject.instance_variable_set(:@secret, 'secret') @@ -30,4 +42,14 @@ describe PersonalFileUploader do ) end end + + describe "#migrate!" do + before do + uploader.store!(fixture_file_upload(Rails.root.join('spec/fixtures/doc_sample.txt'))) + stub_uploads_object_storage + end + + it_behaves_like "migrates", to_store: described_class::Store::REMOTE + it_behaves_like "migrates", from_store: described_class::Store::REMOTE, to_store: described_class::Store::LOCAL + end end diff --git a/spec/views/ci/lints/show.html.haml_spec.rb b/spec/views/projects/ci/lints/show.html.haml_spec.rb index 7724d54c569..2f0cd38c14a 100644 --- a/spec/views/ci/lints/show.html.haml_spec.rb +++ b/spec/views/projects/ci/lints/show.html.haml_spec.rb @@ -1,11 +1,13 @@ require 'spec_helper' -describe 'ci/lints/show' do +describe 'projects/ci/lints/show' do include Devise::Test::ControllerHelpers + let(:project) { create(:project, :repository) } + let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) } describe 'XSS protection' do - let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) } before do + assign(:project, project) assign(:status, true) assign(:builds, config_processor.builds) assign(:stages, config_processor.stages) @@ -47,22 +49,21 @@ describe 'ci/lints/show' do end end - let(:content) do - { - build_template: { - script: './build.sh', - tags: ['dotnet'], - only: ['test@dude/repo'], - except: ['deploy'], - environment: 'testing' + context 'when the content is valid' do + let(:content) do + { + build_template: { + script: './build.sh', + tags: ['dotnet'], + only: ['test@dude/repo'], + except: ['deploy'], + environment: 'testing' + } } - } - end - - let(:config_processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(content)) } + end - context 'when the content is valid' do before do + assign(:project, project) assign(:status, true) assign(:builds, config_processor.builds) assign(:stages, config_processor.stages) @@ -82,6 +83,7 @@ describe 'ci/lints/show' do context 'when the content is invalid' do before do + assign(:project, project) assign(:status, false) assign(:error, 'Undefined error') end diff --git a/spec/views/projects/diffs/_stats.html.haml_spec.rb b/spec/views/projects/diffs/_stats.html.haml_spec.rb new file mode 100644 index 00000000000..c7d2f85747c --- /dev/null +++ b/spec/views/projects/diffs/_stats.html.haml_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'projects/diffs/_stats.html.haml' do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') } + + def render_view + render partial: "projects/diffs/stats", locals: { diff_files: commit.diffs.diff_files } + end + + context 'when the commit contains several changes' do + it 'uses plural for additions' do + render_view + + expect(rendered).to have_text('additions') + end + + it 'uses plural for deletions' do + render_view + end + end + + context 'when the commit contains no addition and no deletions' do + let(:commit) { project.commit('4cd80ccab63c82b4bad16faa5193fbd2aa06df40') } + + it 'uses plural for additions' do + render_view + + expect(rendered).to have_text('additions') + end + + it 'uses plural for deletions' do + render_view + + expect(rendered).to have_text('deletions') + end + end + + context 'when the commit contains exactly one addition and one deletion' do + let(:commit) { project.commit('08f22f255f082689c0d7d39d19205085311542bc') } + + it 'uses singular for additions' do + render_view + + expect(rendered).to have_text('addition') + expect(rendered).not_to have_text('additions') + end + + it 'uses singular for deletions' do + render_view + + expect(rendered).to have_text('deletion') + expect(rendered).not_to have_text('deletions') + end + end +end diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb new file mode 100644 index 00000000000..85167bca115 --- /dev/null +++ b/spec/views/projects/services/_form.haml_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'projects/services/_form' do + let(:project) { create(:redmine_project) } + let(:user) { create(:admin) } + + before do + assign(:project, project) + + allow(controller).to receive(:current_user).and_return(user) + + allow(view).to receive_messages(current_user: user, + can?: true, + current_application_settings: Gitlab::CurrentSettings.current_application_settings) + end + + context 'commit_events and merge_request_events' do + before do + assign(:service, project.redmine_service) + end + + it 'display merge_request_events and commit_events descriptions' do + allow(RedmineService).to receive(:supported_events).and_return(%w(commit merge_request)) + + render + + expect(rendered).to have_content('Event will be triggered when a commit is created/updated') + expect(rendered).to have_content('Event will be triggered when a merge request is created/updated/merged') + end + + context 'when service is JIRA' do + let(:project) { create(:jira_project) } + + before do + assign(:service, project.jira_service) + end + + it 'display merge_request_events and commit_events descriptions' do + render + + expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a commit.') + expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a merge request.') + end + end + end +end diff --git a/spec/workers/object_storage_upload_worker_spec.rb b/spec/workers/object_storage_upload_worker_spec.rb new file mode 100644 index 00000000000..32ddcbe9757 --- /dev/null +++ b/spec/workers/object_storage_upload_worker_spec.rb @@ -0,0 +1,108 @@ +require 'spec_helper' + +describe ObjectStorageUploadWorker do + let(:local) { ObjectStorage::Store::LOCAL } + let(:remote) { ObjectStorage::Store::REMOTE } + + def perform + described_class.perform_async(uploader_class.name, subject_class, file_field, subject_id) + end + + context 'for LFS' do + let!(:lfs_object) { create(:lfs_object, :with_file, file_store: local) } + let(:uploader_class) { LfsObjectUploader } + let(:subject_class) { LfsObject } + let(:file_field) { :file } + let(:subject_id) { lfs_object.id } + + context 'when object storage is enabled' do + before do + stub_lfs_object_storage(background_upload: true) + end + + it 'uploads object to storage' do + expect { perform }.to change { lfs_object.reload.file_store }.from(local).to(remote) + end + + context 'when background upload is disabled' do + before do + allow(Gitlab.config.lfs.object_store).to receive(:background_upload) { false } + end + + it 'is skipped' do + expect { perform }.not_to change { lfs_object.reload.file_store } + end + end + end + + context 'when object storage is disabled' do + before do + stub_lfs_object_storage(enabled: false) + end + + it "doesn't migrate files" do + perform + + expect(lfs_object.reload.file_store).to eq(local) + end + end + end + + context 'for legacy artifacts' do + let(:build) { create(:ci_build, :legacy_artifacts) } + let(:uploader_class) { LegacyArtifactUploader } + let(:subject_class) { Ci::Build } + let(:file_field) { :artifacts_file } + let(:subject_id) { build.id } + + context 'when local storage is used' do + let(:store) { local } + + context 'and remote storage is defined' do + before do + stub_artifacts_object_storage(background_upload: true) + end + + it "migrates file to remote storage" do + perform + + expect(build.reload.artifacts_file_store).to eq(remote) + end + + context 'for artifacts_metadata' do + let(:file_field) { :artifacts_metadata } + + it 'migrates metadata to remote storage' do + perform + + expect(build.reload.artifacts_metadata_store).to eq(remote) + end + end + end + end + end + + context 'for job artifacts' do + let(:artifact) { create(:ci_job_artifact, :archive) } + let(:uploader_class) { JobArtifactUploader } + let(:subject_class) { Ci::JobArtifact } + let(:file_field) { :file } + let(:subject_id) { artifact.id } + + context 'when local storage is used' do + let(:store) { local } + + context 'and remote storage is defined' do + before do + stub_artifacts_object_storage(background_upload: true) + end + + it "migrates file to remote storage" do + perform + + expect(artifact.reload.file_store).to eq(remote) + end + end + end + end +end |