diff options
Diffstat (limited to 'spec')
1057 files changed, 25386 insertions, 13909 deletions
diff --git a/spec/commands/metrics_server/metrics_server_spec.rb b/spec/commands/metrics_server/metrics_server_spec.rb index f3936e6b346..b755801bb65 100644 --- a/spec/commands/metrics_server/metrics_server_spec.rb +++ b/spec/commands/metrics_server/metrics_server_spec.rb @@ -23,6 +23,8 @@ RSpec.describe 'bin/metrics-server', :aggregate_failures do end context 'with a running server' do + let(:metrics_dir) { Dir.mktmpdir } + before do # We need to send a request to localhost WebMock.allow_net_connect! @@ -33,7 +35,8 @@ RSpec.describe 'bin/metrics-server', :aggregate_failures do env = { 'GITLAB_CONFIG' => config_file.path, 'METRICS_SERVER_TARGET' => 'sidekiq', - 'WIPE_METRICS_DIR' => '1' + 'WIPE_METRICS_DIR' => '1', + 'prometheus_multiproc_dir' => metrics_dir } @pid = Process.spawn(env, 'bin/metrics-server', pgroup: true) end @@ -55,6 +58,7 @@ RSpec.describe 'bin/metrics-server', :aggregate_failures do # 'No such process' means the process died before ensure config_file.unlink + FileUtils.rm_rf(metrics_dir, secure: true) end it 'serves /metrics endpoint' do diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb index 148b8720740..d7488e8d965 100644 --- a/spec/commands/sidekiq_cluster/cli_spec.rb +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -5,7 +5,7 @@ require 'rspec-parameterized' require_relative '../../../sidekiq_cluster/cli' -RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath +RSpec.describe Gitlab::SidekiqCluster::CLI, stubbing_settings_source: true do # rubocop:disable RSpec/FilePath let(:cli) { described_class.new('/dev/null') } let(:timeout) { Gitlab::SidekiqCluster::DEFAULT_SOFT_TIMEOUT_SECONDS } let(:default_options) do @@ -16,19 +16,39 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath let(:sidekiq_exporter_port) { '3807' } let(:sidekiq_health_checks_port) { '3807' } - before do - stub_env('RAILS_ENV', 'test') - stub_config( - monitoring: { - sidekiq_exporter: { - enabled: sidekiq_exporter_enabled, - port: sidekiq_exporter_port - }, - sidekiq_health_checks: { - port: sidekiq_health_checks_port + let(:config_file) { Tempfile.new('gitlab.yml') } + let(:config) do + { + 'test' => { + 'monitoring' => { + 'sidekiq_exporter' => { + 'address' => 'localhost', + 'enabled' => sidekiq_exporter_enabled, + 'port' => sidekiq_exporter_port + }, + 'sidekiq_health_checks' => { + 'address' => 'localhost', + 'enabled' => sidekiq_exporter_enabled, + 'port' => sidekiq_health_checks_port + } } } - ) + } + end + + before do + stub_env('RAILS_ENV', 'test') + + config_file.write(YAML.dump(config)) + config_file.close + + allow(::Settings).to receive(:source).and_return(config_file.path) + + ::Settings.reload! + end + + after do + config_file.unlink end describe '#run' do @@ -272,16 +292,9 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath context 'starting the server' do context 'without --dryrun' do context 'when there are no sidekiq_health_checks settings set' do - before do - stub_config( - monitoring: { - sidekiq_exporter: { - enabled: true, - port: sidekiq_exporter_port - } - } - ) + let(:sidekiq_exporter_enabled) { true } + before do allow(Gitlab::SidekiqCluster).to receive(:start) allow(cli).to receive(:write_pid) allow(cli).to receive(:trap_signals) @@ -293,25 +306,42 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath cli.run(%w(foo)) end - - it 'rescues Settingslogic::MissingSetting' do - expect { cli.run(%w(foo)) }.not_to raise_error(Settingslogic::MissingSetting) - end end context 'when the sidekiq_exporter.port setting is not set' do + let(:sidekiq_exporter_enabled) { true } + before do - stub_config( - monitoring: { - sidekiq_exporter: { - enabled: true - }, - sidekiq_health_checks: { - port: sidekiq_health_checks_port + allow(Gitlab::SidekiqCluster).to receive(:start) + allow(cli).to receive(:write_pid) + allow(cli).to receive(:trap_signals) + allow(cli).to receive(:start_loop) + end + + it 'does not start a sidekiq metrics server' do + expect(MetricsServer).not_to receive(:spawn) + + cli.run(%w(foo)) + end + end + + context 'when sidekiq_exporter.enabled setting is not set' do + let(:config) do + { + 'test' => { + 'monitoring' => { + 'sidekiq_exporter' => {}, + 'sidekiq_health_checks' => { + 'address' => 'localhost', + 'enabled' => sidekiq_exporter_enabled, + 'port' => sidekiq_health_checks_port + } } } - ) + } + end + before do allow(Gitlab::SidekiqCluster).to receive(:start) allow(cli).to receive(:write_pid) allow(cli).to receive(:trap_signals) @@ -323,23 +353,21 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath cli.run(%w(foo)) end - - it 'rescues Settingslogic::MissingSetting' do - expect { cli.run(%w(foo)) }.not_to raise_error(Settingslogic::MissingSetting) - end end - context 'when sidekiq_exporter.enabled setting is not set' do - before do - stub_config( - monitoring: { - sidekiq_exporter: {}, - sidekiq_health_checks: { - port: sidekiq_health_checks_port + context 'with a blank sidekiq_exporter setting' do + let(:config) do + { + 'test' => { + 'monitoring' => { + 'sidekiq_exporter' => nil, + 'sidekiq_health_checks' => nil } } - ) + } + end + before do allow(Gitlab::SidekiqCluster).to receive(:start) allow(cli).to receive(:write_pid) allow(cli).to receive(:trap_signals) @@ -351,6 +379,10 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath cli.run(%w(foo)) end + + it 'does not throw an error' do + expect { cli.run(%w(foo)) }.not_to raise_error + end end context 'with valid settings' do diff --git a/spec/config/inject_enterprise_edition_module_spec.rb b/spec/config/inject_enterprise_edition_module_spec.rb index 61b40e46001..6ef74a2b616 100644 --- a/spec/config/inject_enterprise_edition_module_spec.rb +++ b/spec/config/inject_enterprise_edition_module_spec.rb @@ -11,7 +11,7 @@ RSpec.describe InjectEnterpriseEditionModule do before do # Make sure we're not relying on which mode we're running under - allow(Gitlab).to receive(:extensions).and_return([extension_name.downcase]) + allow(GitlabEdition).to receive(:extensions).and_return([extension_name.downcase]) # Test on an imagined extension and imagined class stub_const(fish_name, fish_class) # Fish diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb index 55f8fdd78ba..ec306837361 100644 --- a/spec/config/mail_room_spec.rb +++ b/spec/config/mail_room_spec.rb @@ -18,8 +18,9 @@ RSpec.describe 'mail_room.yml' do result = Gitlab::Popen.popen_with_detail(%W(ruby -rerb -e #{cmd}), absolute_path('config'), vars) output = result.stdout + errors = result.stderr status = result.status - raise "Error interpreting #{mailroom_config_path}: #{output}" unless status == 0 + raise "Error interpreting #{mailroom_config_path}: #{output}\n#{errors}" unless status == 0 YAML.safe_load(output, permitted_classes: [Symbol]) end diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb index 478bd1b7f0a..fb4c0970653 100644 --- a/spec/controllers/admin/application_settings_controller_spec.rb +++ b/spec/controllers/admin/application_settings_controller_spec.rb @@ -62,6 +62,7 @@ RSpec.describe Admin::ApplicationSettingsController, :do_not_mock_admin_mode_set describe 'GET #usage_data' do before do stub_usage_data_connections + stub_database_flavor_check sign_in(admin) end diff --git a/spec/controllers/admin/instance_review_controller_spec.rb b/spec/controllers/admin/instance_review_controller_spec.rb index 898cd30cdca..2169be4e70c 100644 --- a/spec/controllers/admin/instance_review_controller_spec.rb +++ b/spec/controllers/admin/instance_review_controller_spec.rb @@ -22,6 +22,7 @@ RSpec.describe Admin::InstanceReviewController do before do stub_application_setting(usage_ping_enabled: true) stub_usage_data_connections + stub_database_flavor_check ::Gitlab::UsageData.data(force_refresh: true) subject end diff --git a/spec/controllers/admin/runner_projects_controller_spec.rb b/spec/controllers/admin/runner_projects_controller_spec.rb new file mode 100644 index 00000000000..e5f63025cf7 --- /dev/null +++ b/spec/controllers/admin/runner_projects_controller_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::RunnerProjectsController do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + before do + sign_in(create(:admin)) + end + + describe '#create' do + let(:project_id) { project.path } + + subject do + post :create, params: { + namespace_id: group.path, + project_id: project_id, + runner_project: { runner_id: project_runner.id } + } + end + + context 'assigning runner to same project' do + let(:project_runner) { create(:ci_runner, :project, projects: [project]) } + + it 'redirects to the admin runner edit page' do + subject + + expect(response).to have_gitlab_http_status(:redirect) + expect(response).to redirect_to edit_admin_runner_url(project_runner) + end + end + + context 'assigning runner to another project' do + let(:project_runner) { create(:ci_runner, :project, projects: [source_project]) } + let(:source_project) { create(:project) } + + it 'redirects to the admin runner edit page' do + subject + + expect(response).to have_gitlab_http_status(:redirect) + expect(response).to redirect_to edit_admin_runner_url(project_runner) + end + end + + context 'for unknown project' do + let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) } + + let(:project_id) { 0 } + + it 'shows 404 for unknown project' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/controllers/admin/runners_controller_spec.rb b/spec/controllers/admin/runners_controller_spec.rb index b9a59e9ae5f..08fb12c375e 100644 --- a/spec/controllers/admin/runners_controller_spec.rb +++ b/spec/controllers/admin/runners_controller_spec.rb @@ -26,6 +26,32 @@ RSpec.describe Admin::RunnersController do render_views let_it_be(:project) { create(:project) } + + before_all do + create(:ci_build, runner: runner, project: project) + end + + it 'shows a runner show page' do + get :show, params: { id: runner.id } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + end + + it 'when runner_read_only_admin_view is off, redirects to the runner edit page' do + stub_feature_flags(runner_read_only_admin_view: false) + + get :show, params: { id: runner.id } + + expect(response).to have_gitlab_http_status(:redirect) + expect(response).to redirect_to edit_admin_runner_path(runner) + end + end + + describe '#edit' do + render_views + + let_it_be(:project) { create(:project) } let_it_be(:project_two) { create(:project) } before_all do @@ -33,29 +59,29 @@ RSpec.describe Admin::RunnersController do create(:ci_build, runner: runner, project: project_two) end - it 'shows a particular runner' do - get :show, params: { id: runner.id } + it 'shows a runner edit page' do + get :edit, params: { id: runner.id } expect(response).to have_gitlab_http_status(:ok) end it 'shows 404 for unknown runner' do - get :show, params: { id: 0 } + get :edit, params: { id: 0 } expect(response).to have_gitlab_http_status(:not_found) end it 'avoids N+1 queries', :request_store do - get :show, params: { id: runner.id } + get :edit, params: { id: runner.id } - control_count = ActiveRecord::QueryRecorder.new { get :show, params: { id: runner.id } }.count + control_count = ActiveRecord::QueryRecorder.new { get :edit, params: { id: runner.id } }.count new_project = create(:project) create(:ci_build, runner: runner, project: new_project) # There is one additional query looking up subject.group in ProjectPolicy for the # needs_new_sso_session permission - expect { get :show, params: { id: runner.id } }.not_to exceed_query_limit(control_count + 1) + expect { get :edit, params: { id: runner.id } }.not_to exceed_query_limit(control_count + 1) expect(response).to have_gitlab_http_status(:ok) end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 3a2b5dcb99d..c52223d4758 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -421,16 +421,37 @@ RSpec.describe Admin::UsersController do end describe 'PUT confirm/:id' do - let(:user) { create(:user, confirmed_at: nil) } + shared_examples_for 'confirms the user' do + it 'confirms the user' do + put :confirm, params: { id: user.username } + user.reload + expect(user.confirmed?).to be_truthy + end + end + + let(:expired_confirmation_sent_at) { Date.today - User.confirm_within - 7.days } + let(:extant_confirmation_sent_at) { Date.today } + + let(:user) do + create(:user, :unconfirmed).tap do |user| + user.update!(confirmation_sent_at: confirmation_sent_at) + end + end before do request.env["HTTP_REFERER"] = "/" end - it 'confirms user' do - put :confirm, params: { id: user.username } - user.reload - expect(user.confirmed?).to be_truthy + context 'when the confirmation period has expired' do + let(:confirmation_sent_at) { expired_confirmation_sent_at } + + it_behaves_like 'confirms the user' + end + + context 'when the confirmation period has not expired' do + let(:confirmation_sent_at) { extant_confirmation_sent_at } + + it_behaves_like 'confirms the user' end end @@ -591,8 +612,8 @@ RSpec.describe Admin::UsersController do end context 'when the new password does not match the password confirmation' do - let(:password) { 'some_password' } - let(:password_confirmation) { 'not_same_as_password' } + let(:password) { Gitlab::Password.test_default } + let(:password_confirmation) { "not" + Gitlab::Password.test_default } it 'shows the edit page again' do update_password(user, password, password_confirmation) diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index c2eb9d54303..6ccba866ebb 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -234,6 +234,18 @@ RSpec.describe AutocompleteController do expect(json_response.first).to have_key('can_merge') end end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get(:users, params: { search: 'foo@bar.com' }) + end + + before do + sign_in(current_user) + end + end end context 'GET projects' do diff --git a/spec/controllers/concerns/check_rate_limit_spec.rb b/spec/controllers/concerns/check_rate_limit_spec.rb new file mode 100644 index 00000000000..34ececfe639 --- /dev/null +++ b/spec/controllers/concerns/check_rate_limit_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CheckRateLimit do + let(:key) { :some_key } + let(:scope) { [:some, :scope] } + let(:request) { instance_double('Rack::Request') } + let(:user) { build_stubbed(:user) } + + let(:controller_class) do + Class.new do + include CheckRateLimit + + attr_reader :request, :current_user + + def initialize(request, current_user) + @request = request + @current_user = current_user + end + + def redirect_back_or_default(**args) + end + + def render(**args) + end + end + end + + subject { controller_class.new(request, user) } + + before do + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?) + allow(::Gitlab::ApplicationRateLimiter).to receive(:log_request) + end + + describe '#check_rate_limit!' do + it 'calls ApplicationRateLimiter#throttled? with the right arguments' do + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(key, scope: scope).and_return(false) + expect(subject).not_to receive(:render) + + subject.check_rate_limit!(key, scope: scope) + end + + it 'renders error and logs request if throttled' do + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(key, scope: scope).and_return(true) + expect(::Gitlab::ApplicationRateLimiter).to receive(:log_request).with(request, "#{key}_request_limit".to_sym, user) + expect(subject).to receive(:render).with({ plain: _('This endpoint has been requested too many times. Try again later.'), status: :too_many_requests }) + + subject.check_rate_limit!(key, scope: scope) + end + + it 'redirects back if throttled and redirect_back option is set to true' do + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(key, scope: scope).and_return(true) + expect(::Gitlab::ApplicationRateLimiter).to receive(:log_request).with(request, "#{key}_request_limit".to_sym, user) + expect(subject).not_to receive(:render) + expect(subject).to receive(:redirect_back_or_default).with(options: { alert: _('This endpoint has been requested too many times. Try again later.') }) + + subject.check_rate_limit!(key, scope: scope, redirect_back: true) + end + + context 'when the bypass header is set' do + before do + allow(Gitlab::Throttle).to receive(:bypass_header).and_return('SOME_HEADER') + end + + it 'skips rate limit if set to "1"' do + allow(request).to receive(:get_header).with(Gitlab::Throttle.bypass_header).and_return('1') + + expect(::Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) + expect(subject).not_to receive(:render) + + subject.check_rate_limit!(key, scope: scope) + end + + it 'does not skip rate limit if set to something else than "1"' do + allow(request).to receive(:get_header).with(Gitlab::Throttle.bypass_header).and_return('0') + + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?) + + subject.check_rate_limit!(key, scope: scope) + end + end + end +end diff --git a/spec/controllers/groups/boards_controller_spec.rb b/spec/controllers/groups/boards_controller_spec.rb index ca4931bdc90..6201cddecb0 100644 --- a/spec/controllers/groups/boards_controller_spec.rb +++ b/spec/controllers/groups/boards_controller_spec.rb @@ -16,15 +16,6 @@ RSpec.describe Groups::BoardsController do expect { list_boards }.to change(group.boards, :count).by(1) end - it 'pushes swimlanes_buffered_rendering feature flag' do - allow(controller).to receive(:push_frontend_feature_flag).and_call_original - - expect(controller).to receive(:push_frontend_feature_flag) - .with(:swimlanes_buffered_rendering, group, default_enabled: :yaml) - - list_boards - end - context 'when format is HTML' do it 'renders template' do list_boards @@ -107,15 +98,6 @@ RSpec.describe Groups::BoardsController do describe 'GET show' do let!(:board) { create(:board, group: group) } - it 'pushes swimlanes_buffered_rendering feature flag' do - allow(controller).to receive(:push_frontend_feature_flag).and_call_original - - expect(controller).to receive(:push_frontend_feature_flag) - .with(:swimlanes_buffered_rendering, group, default_enabled: :yaml) - - read_board board: board - end - context 'when format is HTML' do it 'renders template' do expect { read_board board: board }.to change(BoardGroupRecentVisit, :count).by(1) diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index 0f262d93d4c..f438be534fa 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -178,10 +178,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do subject { get_manifest(tag) } context 'feature enabled' do - before do - enable_dependency_proxy - end - it_behaves_like 'without a token' it_behaves_like 'without permission' it_behaves_like 'feature flag disabled with private group' @@ -270,7 +266,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do let_it_be_with_reload(:group) { create(:group, parent: parent_group) } before do - parent_group.create_dependency_proxy_setting!(enabled: true) group_deploy_token.update_column(:group_id, parent_group.id) end @@ -294,10 +289,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do subject { get_blob } context 'feature enabled' do - before do - enable_dependency_proxy - end - it_behaves_like 'without a token' it_behaves_like 'without permission' it_behaves_like 'feature flag disabled with private group' @@ -341,81 +332,12 @@ RSpec.describe Groups::DependencyProxyForContainersController do let_it_be_with_reload(:group) { create(:group, parent: parent_group) } before do - parent_group.create_dependency_proxy_setting!(enabled: true) group_deploy_token.update_column(:group_id, parent_group.id) end it_behaves_like 'a successful blob pull' end end - - context 'when dependency_proxy_workhorse disabled' do - let(:blob_response) { { status: :success, blob: blob, from_cache: false } } - - before do - stub_feature_flags(dependency_proxy_workhorse: false) - - allow_next_instance_of(DependencyProxy::FindOrCreateBlobService) do |instance| - allow(instance).to receive(:execute).and_return(blob_response) - end - end - - context 'remote blob request fails' do - let(:blob_response) do - { - status: :error, - http_status: 400, - message: '' - } - end - - before do - group.add_guest(user) - end - - it 'proxies status from the remote blob request', :aggregate_failures do - subject - - expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to be_empty - end - end - - context 'a valid user' do - before do - group.add_guest(user) - end - - it_behaves_like 'a successful blob pull' - it_behaves_like 'a package tracking event', described_class.name, 'pull_blob' - - context 'with a cache entry' do - let(:blob_response) { { status: :success, blob: blob, from_cache: true } } - - it_behaves_like 'returning response status', :success - it_behaves_like 'a package tracking event', described_class.name, 'pull_blob_from_cache' - end - end - - context 'a valid deploy token' do - let_it_be(:user) { create(:deploy_token, :group, :dependency_proxy_scopes) } - let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: user, group: group) } - - it_behaves_like 'a successful blob pull' - - context 'pulling from a subgroup' do - let_it_be_with_reload(:parent_group) { create(:group) } - let_it_be_with_reload(:group) { create(:group, parent: parent_group) } - - before do - parent_group.create_dependency_proxy_setting!(enabled: true) - group_deploy_token.update_column(:group_id, parent_group.id) - end - - it_behaves_like 'a successful blob pull' - end - end - end end it_behaves_like 'not found when disabled' @@ -542,10 +464,6 @@ RSpec.describe Groups::DependencyProxyForContainersController do end end - def enable_dependency_proxy - group.create_dependency_proxy_setting!(enabled: true) - end - def disable_dependency_proxy group.create_dependency_proxy_setting!(enabled: false) end diff --git a/spec/controllers/groups/packages_controller_spec.rb b/spec/controllers/groups/packages_controller_spec.rb new file mode 100644 index 00000000000..fc9b79da47c --- /dev/null +++ b/spec/controllers/groups/packages_controller_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::PackagesController do + let_it_be(:group) { create(:group) } + + let(:page) { :index } + let(:additional_parameters) { {} } + + subject do + get page, params: additional_parameters.merge({ + group_id: group + }) + end + + context 'GET #index' do + it_behaves_like 'returning response status', :ok + end + + context 'GET #show' do + let(:page) { :show } + let(:additional_parameters) { { id: 1 } } + + it_behaves_like 'returning response status', :ok + end +end diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb index 826625ba9c3..117c934ad5d 100644 --- a/spec/controllers/import/gitlab_controller_spec.rb +++ b/spec/controllers/import/gitlab_controller_spec.rb @@ -30,18 +30,27 @@ RSpec.describe Import::GitlabController do expect(session[:gitlab_access_token]).to eq(token) expect(controller).to redirect_to(status_import_gitlab_url) end + + it "importable_repos should return an array" do + allow_next_instance_of(Gitlab::GitlabImport::Client) do |instance| + allow(instance).to receive(:projects).and_return([{ "id": 1 }].to_enum) + end + + expect(controller.send(:importable_repos)).to be_an_instance_of(Array) + end end describe "GET status" do + let(:repo_fake) { Struct.new(:id, :path, :path_with_namespace, :web_url, keyword_init: true) } + let(:repo) { repo_fake.new(id: 1, path: 'vim', path_with_namespace: 'asd/vim', web_url: 'https://gitlab.com/asd/vim') } + before do - @repo = OpenStruct.new(id: 1, path: 'vim', path_with_namespace: 'asd/vim', web_url: 'https://gitlab.com/asd/vim') assign_session_token end it_behaves_like 'import controller status' do - let(:repo) { @repo } - let(:repo_id) { @repo.id } - let(:import_source) { @repo.path_with_namespace } + let(:repo_id) { repo.id } + let(:import_source) { repo.path_with_namespace } let(:provider_name) { 'gitlab' } let(:client_repos_field) { :projects } end diff --git a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb index ecff173b8ac..29678706bba 100644 --- a/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/ldap/omniauth_callbacks_controller_spec.rb @@ -58,7 +58,7 @@ RSpec.describe Ldap::OmniauthCallbacksController do end context 'sign up' do - let(:user) { double(email: +'new@example.com') } + let(:user) { create(:user) } before do stub_omniauth_setting(block_auto_created_users: false) diff --git a/spec/controllers/oauth/token_info_controller_spec.rb b/spec/controllers/oauth/token_info_controller_spec.rb index 6d01a534673..b66fff4d4e9 100644 --- a/spec/controllers/oauth/token_info_controller_spec.rb +++ b/spec/controllers/oauth/token_info_controller_spec.rb @@ -5,11 +5,11 @@ require 'spec_helper' RSpec.describe Oauth::TokenInfoController do describe '#show' do context 'when the user is not authenticated' do - it 'responds with a 400' do + it 'responds with a 401' do get :show - expect(response).to have_gitlab_http_status(:bad_request) - expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request') + expect(response).to have_gitlab_http_status(:unauthorized) + expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_token') end end @@ -36,11 +36,11 @@ RSpec.describe Oauth::TokenInfoController do end context 'when the doorkeeper_token is not recognised' do - it 'responds with a 400' do + it 'responds with a 401' do get :show, params: { access_token: 'unknown_token' } - expect(response).to have_gitlab_http_status(:bad_request) - expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request') + expect(response).to have_gitlab_http_status(:unauthorized) + expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_token') end end @@ -49,22 +49,22 @@ RSpec.describe Oauth::TokenInfoController do create(:oauth_access_token, created_at: 2.days.ago, expires_in: 10.minutes) end - it 'responds with a 400' do + it 'responds with a 401' do get :show, params: { access_token: access_token.token } - expect(response).to have_gitlab_http_status(:bad_request) - expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request') + expect(response).to have_gitlab_http_status(:unauthorized) + expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_token') end end context 'when the token is revoked' do let(:access_token) { create(:oauth_access_token, revoked_at: 2.days.ago) } - it 'responds with a 400' do + it 'responds with a 401' do get :show, params: { access_token: access_token.token } - expect(response).to have_gitlab_http_status(:bad_request) - expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_request') + expect(response).to have_gitlab_http_status(:unauthorized) + expect(Gitlab::Json.parse(response.body)).to include('error' => 'invalid_token') end end end diff --git a/spec/controllers/profiles/emails_controller_spec.rb b/spec/controllers/profiles/emails_controller_spec.rb index 214a893f0fa..e41ae406d13 100644 --- a/spec/controllers/profiles/emails_controller_spec.rb +++ b/spec/controllers/profiles/emails_controller_spec.rb @@ -49,7 +49,7 @@ RSpec.describe Profiles::EmailsController do end context 'when email address is invalid' do - let(:email) { 'invalid.@example.com' } + let(:email) { 'invalid@@example.com' } it 'does not send an email confirmation' do expect { subject }.not_to change { ActionMailer::Base.deliveries.size } diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index 9a1f8a8442d..6e7cc058fbc 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -153,9 +153,12 @@ RSpec.describe ProfilesController, :request_store do let(:gitlab_shell) { Gitlab::Shell.new } let(:new_username) { generate(:username) } - it 'allows username change' do + before do sign_in(user) + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(false) + end + it 'allows username change' do put :update_username, params: { user: { username: new_username } } @@ -166,8 +169,6 @@ RSpec.describe ProfilesController, :request_store do end it 'updates a username using JSON request' do - sign_in(user) - put :update_username, params: { user: { username: new_username } @@ -179,8 +180,6 @@ RSpec.describe ProfilesController, :request_store do end it 'renders an error message when the username was not updated' do - sign_in(user) - put :update_username, params: { user: { username: 'invalid username.git' } @@ -192,8 +191,6 @@ RSpec.describe ProfilesController, :request_store do end it 'raises a correct error when the username is missing' do - sign_in(user) - expect { put :update_username, params: { user: { gandalf: 'you shall not pass' } } } .to raise_error(ActionController::ParameterMissing) end @@ -202,8 +199,6 @@ RSpec.describe ProfilesController, :request_store do it 'moves dependent projects to new namespace' do project = create(:project_empty_repo, :legacy_storage, namespace: namespace) - sign_in(user) - put :update_username, params: { user: { username: new_username } } @@ -220,8 +215,6 @@ RSpec.describe ProfilesController, :request_store do before_disk_path = project.disk_path - sign_in(user) - put :update_username, params: { user: { username: new_username } } @@ -232,5 +225,18 @@ RSpec.describe ProfilesController, :request_store do expect(before_disk_path).to eq(project.disk_path) end end + + context 'when the rate limit is reached' do + it 'does not update the username and returns status 429 Too Many Requests' do + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:profile_update_username, scope: user).and_return(true) + + expect do + put :update_username, + params: { user: { username: new_username } } + end.not_to change { user.reload.username } + + expect(response).to have_gitlab_http_status(:too_many_requests) + end + end end end diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb index 48a12a27911..cde3a8d4761 100644 --- a/spec/controllers/projects/boards_controller_spec.rb +++ b/spec/controllers/projects/boards_controller_spec.rb @@ -22,15 +22,6 @@ RSpec.describe Projects::BoardsController do expect(assigns(:boards_endpoint)).to eq project_boards_path(project) end - it 'pushes swimlanes_buffered_rendering feature flag' do - allow(controller).to receive(:push_frontend_feature_flag).and_call_original - - expect(controller).to receive(:push_frontend_feature_flag) - .with(:swimlanes_buffered_rendering, project, default_enabled: :yaml) - - list_boards - end - context 'when format is HTML' do it 'renders template' do list_boards @@ -125,15 +116,6 @@ RSpec.describe Projects::BoardsController do describe 'GET show' do let!(:board) { create(:board, project: project) } - it 'pushes swimlanes_buffered_rendering feature flag' do - allow(controller).to receive(:push_frontend_feature_flag).and_call_original - - expect(controller).to receive(:push_frontend_feature_flag) - .with(:swimlanes_buffered_rendering, project, default_enabled: :yaml) - - read_board board: board - end - it 'sets boards_endpoint instance variable to a boards path' do read_board board: board diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb index edec8c3e9c6..596cd5c1a20 100644 --- a/spec/controllers/projects/mattermosts_controller_spec.rb +++ b/spec/controllers/projects/mattermosts_controller_spec.rb @@ -60,9 +60,9 @@ RSpec.describe Projects::MattermostsController do it 'redirects to the new page' do subject - service = project.integrations.last + integration = project.integrations.last - expect(subject).to redirect_to(edit_project_service_url(project, service)) + expect(subject).to redirect_to(edit_project_integration_path(project, integration)) end end end diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index f7370a1a1ac..a5c59b7e22d 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -205,7 +205,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiff } let(:expected_options) do { - environment: nil, merge_request: merge_request, merge_request_diff: merge_request.merge_request_diff, merge_request_diffs: merge_request.merge_request_diffs, @@ -280,7 +279,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiff } let(:expected_options) do { - environment: nil, merge_request: merge_request, merge_request_diff: merge_request.merge_request_diff, merge_request_diffs: merge_request.merge_request_diffs, @@ -303,7 +301,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do let(:collection) { Gitlab::Diff::FileCollection::Commit } let(:expected_options) do { - environment: nil, merge_request: merge_request, merge_request_diff: nil, merge_request_diffs: merge_request.merge_request_diffs, @@ -330,7 +327,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiff } let(:expected_options) do { - environment: nil, merge_request: merge_request, merge_request_diff: merge_request.merge_request_diff, merge_request_diffs: merge_request.merge_request_diffs, @@ -494,7 +490,6 @@ RSpec.describe Projects::MergeRequests::DiffsController do def collection_arguments(pagination_data = {}) { - environment: nil, merge_request: merge_request, commit: nil, diff_view: :inline, diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 66af546b113..2df31904380 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -762,9 +762,12 @@ RSpec.describe Projects::NotesController do end end - it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do - let(:params) { request_params.except(:format) } - let(:request_full_path) { project_notes_path(project) } + it_behaves_like 'create notes request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { user } + + def request + post :create, params: request_params.except(:format) + end end end diff --git a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb index fc741d0f3f6..707edeaeee3 100644 --- a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb +++ b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb @@ -41,5 +41,29 @@ RSpec.describe Projects::Packages::InfrastructureRegistryController do it_behaves_like 'returning response status', :not_found end + + context 'with package file pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: terraform_module) } + + let(:terraform_module_package_file) { terraform_module.package_files.first } + + it 'does not return them' do + subject + + expect(assigns(:package_files)).to contain_exactly(terraform_module_package_file) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + subject + + expect(assigns(:package_files)).to contain_exactly(package_file_pending_destruction, terraform_module_package_file) + end + end + end end end diff --git a/spec/controllers/projects/packages/packages_controller_spec.rb b/spec/controllers/projects/packages/packages_controller_spec.rb new file mode 100644 index 00000000000..da9cae47c62 --- /dev/null +++ b/spec/controllers/projects/packages/packages_controller_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Packages::PackagesController do + let_it_be(:project) { create(:project, :public) } + + let(:page) { :index } + let(:additional_parameters) { {} } + + subject do + get page, params: additional_parameters.merge({ + project_id: project, + namespace_id: project.namespace + }) + end + + context 'GET #index' do + it_behaves_like 'returning response status', :ok + end + + context 'GET #show' do + let(:page) { :show } + let(:additional_parameters) { { id: 1 } } + + it_behaves_like 'returning response status', :ok + end +end diff --git a/spec/controllers/projects/prometheus/metrics_controller_spec.rb b/spec/controllers/projects/prometheus/metrics_controller_spec.rb index 5338b77bd08..7dfa283195e 100644 --- a/spec/controllers/projects/prometheus/metrics_controller_spec.rb +++ b/spec/controllers/projects/prometheus/metrics_controller_spec.rb @@ -141,7 +141,7 @@ RSpec.describe Projects::Prometheus::MetricsController do expect(flash[:notice]).to include('Metric was successfully added.') - expect(response).to redirect_to(edit_project_service_path(project, ::Integrations::Prometheus)) + expect(response).to redirect_to(edit_project_integration_path(project, ::Integrations::Prometheus)) end end @@ -157,6 +157,22 @@ RSpec.describe Projects::Prometheus::MetricsController do end end + describe 'PUT #update' do + context 'metric is updated' do + let_it_be(:metric) { create(:prometheus_metric, project: project) } + + let(:metric_params) { { prometheus_metric: { title: 'new_title' }, id: metric.id } } + + it 'shows a success flash message' do + put :update, params: project_params(metric_params) + + expect(metric.reload.title).to eq('new_title') + expect(flash[:notice]).to include('Metric was successfully updated.') + expect(response).to redirect_to(edit_project_integration_path(project, ::Integrations::Prometheus)) + end + end + end + describe 'DELETE #destroy' do context 'format html' do let!(:metric) { create(:prometheus_metric, project: project) } @@ -164,7 +180,7 @@ RSpec.describe Projects::Prometheus::MetricsController do it 'destroys the metric' do delete :destroy, params: project_params(id: metric.id) - expect(response).to redirect_to(edit_project_service_path(project, ::Integrations::Prometheus)) + expect(response).to redirect_to(edit_project_integration_path(project, ::Integrations::Prometheus)) expect(PrometheusMetric.find_by(id: metric.id)).to be_nil end end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 4d99afb6b1f..e0d88fa799f 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Projects::RawController do let_it_be(:project) { create(:project, :public, :repository) } let(:inline) { nil } + let(:params) { {} } describe 'GET #show' do def get_show @@ -15,9 +16,9 @@ RSpec.describe Projects::RawController do params: { namespace_id: project.namespace, project_id: project, - id: filepath, + id: file_path, inline: inline - }) + }.merge(params)) end subject { get_show } @@ -33,7 +34,7 @@ RSpec.describe Projects::RawController do end context 'regular filename' do - let(:filepath) { 'master/CONTRIBUTING.md' } + let(:file_path) { 'master/CONTRIBUTING.md' } it 'delivers ASCII file' do allow(Gitlab::Workhorse).to receive(:send_git_blob).and_call_original @@ -60,7 +61,7 @@ RSpec.describe Projects::RawController do end context 'image header' do - let(:filepath) { 'master/files/images/6049019_460s.jpg' } + let(:file_path) { 'master/files/images/6049019_460s.jpg' } it 'leaves image content disposition' do subject @@ -77,44 +78,30 @@ RSpec.describe Projects::RawController do context 'with LFS files' do let(:filename) { 'lfs_object.iso' } - let(:filepath) { "be93687/files/lfs/#{filename}" } + let(:file_path) { "be93687/files/lfs/#{filename}" } it_behaves_like 'a controller that can serve LFS files' it_behaves_like 'project cache control headers' include_examples 'single Gitaly request' end - context 'when the endpoint receives requests above the limit', :clean_gitlab_redis_rate_limiting do + context 'when the endpoint receives requests above the limit' do let(:file_path) { 'master/README.md' } + let(:path_without_ref) { 'README.md' } before do - stub_application_setting(raw_blob_request_limit: 5) + allow(::Gitlab::ApplicationRateLimiter).to( + receive(:throttled?).with(:raw_blob, scope: [project, path_without_ref]).and_return(true) + ) end - it 'prevents from accessing the raw file', :request_store do - execute_raw_requests(requests: 5, project: project, file_path: file_path) - - expect { execute_raw_requests(requests: 1, project: project, file_path: file_path) } - .to change { Gitlab::GitalyClient.get_request_count }.by(0) + it 'prevents from accessing the raw file' do + expect { get_show }.not_to change { Gitlab::GitalyClient.get_request_count } expect(response.body).to eq(_('You cannot access the raw file. Please wait a minute.')) expect(response).to have_gitlab_http_status(:too_many_requests) end - it 'logs the event on auth.log', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/345889' do - attributes = { - message: 'Application_Rate_Limiter_Request', - env: :raw_blob_request_limit, - remote_ip: '0.0.0.0', - request_method: 'GET', - path: "/#{project.full_path}/-/raw/#{file_path}" - } - - expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once - - execute_raw_requests(requests: 6, project: project, file_path: file_path) - end - context 'when receiving an external storage request' do let(:token) { 'letmein' } @@ -126,62 +113,10 @@ RSpec.describe Projects::RawController do end it 'does not prevent from accessing the raw file' do - request.headers['X-Gitlab-External-Storage-Token'] = token - execute_raw_requests(requests: 6, project: project, file_path: file_path) - - expect(response).to have_gitlab_http_status(:ok) - end - end - - context 'when the request uses a different version of a commit' do - it 'prevents from accessing the raw file' do - # 3 times with the normal sha - commit_sha = project.repository.commit.sha - file_path = "#{commit_sha}/README.md" - - execute_raw_requests(requests: 3, project: project, file_path: file_path) - - # 3 times with the modified version - modified_sha = commit_sha.gsub(commit_sha[0..5], commit_sha[0..5].upcase) - modified_path = "#{modified_sha}/README.md" - - execute_raw_requests(requests: 3, project: project, file_path: modified_path) - - expect(response.body).to eq(_('You cannot access the raw file. Please wait a minute.')) - expect(response).to have_gitlab_http_status(:too_many_requests) - end - end - - context 'when the throttling has been disabled' do - before do - stub_application_setting(raw_blob_request_limit: 0) - end - - it 'does not prevent from accessing the raw file' do - execute_raw_requests(requests: 10, project: project, file_path: file_path) - - expect(response).to have_gitlab_http_status(:ok) - end - end - - context 'with case-sensitive files' do - it 'prevents from accessing the specific file' do - create_file_in_repo(project, 'master', 'master', 'readme.md', 'Add readme.md') - create_file_in_repo(project, 'master', 'master', 'README.md', 'Add README.md') - - commit_sha = project.repository.commit.sha - file_path = "#{commit_sha}/readme.md" - - # Accessing downcase version of readme - execute_raw_requests(requests: 6, project: project, file_path: file_path) - - expect(response.body).to eq(_('You cannot access the raw file. Please wait a minute.')) - expect(response).to have_gitlab_http_status(:too_many_requests) + expect(::Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) - # Accessing upcase version of readme - file_path = "#{commit_sha}/README.md" - - execute_raw_requests(requests: 1, project: project, file_path: file_path) + request.headers['X-Gitlab-External-Storage-Token'] = token + get_show expect(response).to have_gitlab_http_status(:ok) end @@ -201,7 +136,7 @@ RSpec.describe Projects::RawController do context 'when no token is provided' do it 'redirects to sign in page' do - execute_raw_requests(requests: 1, project: project, file_path: file_path) + get_show expect(response).to have_gitlab_http_status(:found) expect(response.location).to end_with('/users/sign_in') @@ -209,13 +144,11 @@ RSpec.describe Projects::RawController do end context 'when a token param is present' do - subject(:execute_raw_request_with_token_in_params) do - execute_raw_requests(requests: 1, project: project, file_path: file_path, token: token) - end - context 'when token is correct' do + let(:params) { { token: token } } + it 'calls the action normally' do - execute_raw_request_with_token_in_params + get_show expect(response).to have_gitlab_http_status(:ok) end @@ -224,7 +157,7 @@ RSpec.describe Projects::RawController do let_it_be(:user) { create(:user, password_expires_at: 2.minutes.ago) } it 'redirects to sign in page' do - execute_raw_request_with_token_in_params + get_show expect(response).to have_gitlab_http_status(:found) expect(response.location).to end_with('/users/sign_in') @@ -236,7 +169,7 @@ RSpec.describe Projects::RawController do let_it_be(:user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } it 'calls the action normally' do - execute_raw_request_with_token_in_params + get_show expect(response).to have_gitlab_http_status(:ok) end @@ -245,10 +178,10 @@ RSpec.describe Projects::RawController do end context 'when token is incorrect' do - let(:token) { 'foobar' } + let(:params) { { token: 'foobar' } } it 'redirects to sign in page' do - execute_raw_request_with_token_in_params + get_show expect(response).to have_gitlab_http_status(:found) expect(response.location).to end_with('/users/sign_in') @@ -257,14 +190,13 @@ RSpec.describe Projects::RawController do end context 'when a token header is present' do - subject(:execute_raw_request_with_token_in_headers) do + before do request.headers['X-Gitlab-Static-Object-Token'] = token - execute_raw_requests(requests: 1, project: project, file_path: file_path) end context 'when token is correct' do it 'calls the action normally' do - execute_raw_request_with_token_in_headers + get_show expect(response).to have_gitlab_http_status(:ok) end @@ -273,7 +205,7 @@ RSpec.describe Projects::RawController do let_it_be(:user) { create(:user, password_expires_at: 2.minutes.ago) } it 'redirects to sign in page' do - execute_raw_request_with_token_in_headers + get_show expect(response).to have_gitlab_http_status(:found) expect(response.location).to end_with('/users/sign_in') @@ -285,7 +217,7 @@ RSpec.describe Projects::RawController do let_it_be(:user) { create(:omniauth_user, provider: 'ldap', password_expires_at: 2.minutes.ago) } it 'calls the action normally' do - execute_raw_request_with_token_in_headers + get_show expect(response).to have_gitlab_http_status(:ok) end @@ -297,7 +229,7 @@ RSpec.describe Projects::RawController do let(:token) { 'foobar' } it 'redirects to sign in page' do - execute_raw_request_with_token_in_headers + get_show expect(response).to have_gitlab_http_status(:found) expect(response.location).to end_with('/users/sign_in') @@ -344,14 +276,4 @@ RSpec.describe Projects::RawController do end end end - - def execute_raw_requests(requests:, project:, file_path:, **params) - requests.times do - get :show, params: { - namespace_id: project.namespace, - project_id: project, - id: file_path - }.merge(params) - end - end end diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb index f7cf55d8a95..1370ec9cc0b 100644 --- a/spec/controllers/projects/repositories_controller_spec.rb +++ b/spec/controllers/projects/repositories_controller_spec.rb @@ -210,6 +210,25 @@ RSpec.describe Projects::RepositoriesController do expect(response).to have_gitlab_http_status(:found) end end + + context 'when token is migrated' do + let(:user) { create(:user, static_object_token: '') } + let(:token) { 'Test' } + + it 'calls the action normally' do + user.update_column(:static_object_token, token) + + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master', token: token }, format: 'zip' + expect(user.static_object_token).to eq(token) + expect(response).to have_gitlab_http_status(:ok) + + user.update_column(:static_object_token_encrypted, Gitlab::CryptoHelper.aes256_gcm_encrypt(token)) + + get :archive, params: { namespace_id: project.namespace, project_id: project, id: 'master', token: token }, format: 'zip' + expect(user.static_object_token).to eq(token) + expect(response).to have_gitlab_http_status(:ok) + end + end end context 'when a token header is present' do diff --git a/spec/controllers/projects/security/configuration_controller_spec.rb b/spec/controllers/projects/security/configuration_controller_spec.rb index 848db16fb02..1ce0fcd85db 100644 --- a/spec/controllers/projects/security/configuration_controller_spec.rb +++ b/spec/controllers/projects/security/configuration_controller_spec.rb @@ -36,6 +36,31 @@ RSpec.describe Projects::Security::ConfigurationController do expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template(:show) end + + it 'responds with configuration data json' do + get :show, params: { namespace_id: project.namespace, project_id: project, format: :json } + + features = json_response['features'] + sast_feature = features.find { |feature| feature['type'] == 'sast' } + dast_feature = features.find { |feature| feature['type'] == 'dast' } + + expect(response).to have_gitlab_http_status(:ok) + expect(sast_feature['available']).to be_truthy + expect(dast_feature['available']).to be_falsey + end + + context 'with feature flag unify_security_configuration turned off' do + before do + stub_feature_flags(unify_security_configuration: false) + end + + it 'responds with empty configuration data json' do + get :show, params: { namespace_id: project.namespace, project_id: project, format: :json } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_empty + end + end end end end diff --git a/spec/controllers/projects/service_hook_logs_controller_spec.rb b/spec/controllers/projects/service_hook_logs_controller_spec.rb index 9caa4a06b44..be78668aa88 100644 --- a/spec/controllers/projects/service_hook_logs_controller_spec.rb +++ b/spec/controllers/projects/service_hook_logs_controller_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Projects::ServiceHookLogsController do { namespace_id: project.namespace, project_id: project, - service_id: integration.to_param, + integration_id: integration.to_param, id: log.id } end @@ -44,7 +44,7 @@ RSpec.describe Projects::ServiceHookLogsController do it 'executes the hook and redirects to the service form' do expect_any_instance_of(ServiceHook).to receive(:execute) expect_any_instance_of(described_class).to receive(:set_hook_execution_notice) - expect(subject).to redirect_to(edit_project_service_path(project, integration)) + expect(subject).to redirect_to(edit_project_integration_path(project, integration)) end it 'renders a 404 if the hook does not exist' do diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 29988da6e60..f3c7b501faa 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -183,7 +183,7 @@ RSpec.describe Projects::ServicesController do let(:params) { project_params(service: integration_params) } let(:message) { 'Jira settings saved and active.' } - let(:redirect_url) { edit_project_service_path(project, integration) } + let(:redirect_url) { edit_project_integration_path(project, integration) } before do stub_jira_integration_test @@ -341,7 +341,7 @@ RSpec.describe Projects::ServicesController do it 'redirects user back to edit page with alert' do put :update, params: project_params.merge(service: integration_params) - expect(response).to redirect_to(edit_project_service_path(project, integration)) + expect(response).to redirect_to(edit_project_integration_path(project, integration)) expected_alert = [ "You can now manage your Prometheus settings on the", %(<a href="#{project_settings_operations_path(project)}">Operations</a> page.), diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index d50f1aa1dd8..7e96e99640a 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -25,6 +25,19 @@ RSpec.describe Projects::Settings::CiCdController do expect(response).to render_template(:show) end + context 'when the FF ci_owned_runners_cross_joins_fix is disabled' do + before do + stub_feature_flags(ci_owned_runners_cross_joins_fix: false) + end + + it 'renders show with 200 status code' do + get :show, params: { namespace_id: project.namespace, project_id: project } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + end + end + context 'with CI/CD disabled' do before do project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED) diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index 3f7941b3456..d5fe32ac094 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -20,6 +20,10 @@ RSpec.describe RegistrationsController do end describe '#create' do + before do + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(false) + end + let_it_be(:base_user_params) do { first_name: 'first', last_name: 'last', username: 'new_username', email: 'new@user.com', password: 'Any_password' } end @@ -410,6 +414,18 @@ RSpec.describe RegistrationsController do end end + context 'when the rate limit has been reached' do + it 'returns status 429 Too Many Requests', :aggregate_failures do + ip = '1.2.3.4' + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:user_sign_up, scope: ip).and_return(true) + + controller.request.env['REMOTE_ADDR'] = ip + post(:create, params: user_params, session: session_params) + + expect(response).to have_gitlab_http_status(:too_many_requests) + end + end + it "logs a 'User Created' message" do expect(Gitlab::AppLogger).to receive(:info).with(/\AUser Created: username=new_username email=new@user.com.+\z/).and_call_original @@ -483,7 +499,7 @@ RSpec.describe RegistrationsController do end it 'succeeds if password is confirmed' do - post :destroy, params: { password: '12345678' } + post :destroy, params: { password: Gitlab::Password.test_default } expect_success end @@ -524,7 +540,7 @@ RSpec.describe RegistrationsController do end it 'fails' do - delete :destroy, params: { password: '12345678' } + delete :destroy, params: { password: Gitlab::Password.test_default } expect_failure(s_('Profiles|You must transfer ownership or delete groups you are an owner of before you can delete your account')) end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index a54f16ec237..58d34a5e5c1 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -290,6 +290,14 @@ RSpec.describe SearchController do expect(assigns[:search_objects].count).to eq(0) end end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get(:show, params: { search: 'foo@bar.com', scope: 'users' }) + end + end end describe 'GET #count', :aggregate_failures do @@ -346,6 +354,14 @@ RSpec.describe SearchController do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq({ 'count' => '0' }) end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get(:count, params: { search: 'foo@bar.com', scope: 'users' }) + end + end end describe 'GET #autocomplete' do @@ -358,6 +374,14 @@ RSpec.describe SearchController do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to match_array([]) end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get(:autocomplete, params: { term: 'foo@bar.com', scope: 'users' }) + end + end end describe '#append_info_to_payload' do @@ -372,9 +396,10 @@ RSpec.describe SearchController do expect(payload[:metadata]['meta.search.force_search_results']).to eq('true') expect(payload[:metadata]['meta.search.filters.confidential']).to eq('true') expect(payload[:metadata]['meta.search.filters.state']).to eq('true') + expect(payload[:metadata]['meta.search.project_ids']).to eq(%w(456 789)) end - get :show, params: { scope: 'issues', search: 'hello world', group_id: '123', project_id: '456', confidential: true, state: true, force_search_results: true } + get :show, params: { scope: 'issues', search: 'hello world', group_id: '123', project_id: '456', project_ids: %w(456 789), confidential: true, state: true, force_search_results: true } end it 'appends the default scope in meta.search.scope' do diff --git a/spec/controllers/snippets/notes_controller_spec.rb b/spec/controllers/snippets/notes_controller_spec.rb index 558e68fbb8f..8e85e283b31 100644 --- a/spec/controllers/snippets/notes_controller_spec.rb +++ b/spec/controllers/snippets/notes_controller_spec.rb @@ -142,9 +142,12 @@ RSpec.describe Snippets::NotesController do expect { post :create, params: request_params }.to change { Note.count }.by(1) end - it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do - let(:params) { request_params } - let(:request_full_path) { snippet_notes_path(public_snippet) } + it_behaves_like 'create notes request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { user } + + def request + post :create, params: request_params + end end end @@ -170,9 +173,12 @@ RSpec.describe Snippets::NotesController do expect { post :create, params: request_params }.to change { Note.count }.by(1) end - it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do - let(:params) { request_params } - let(:request_full_path) { snippet_notes_path(internal_snippet) } + it_behaves_like 'create notes request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { user } + + def request + post :create, params: request_params + end end end @@ -239,10 +245,12 @@ RSpec.describe Snippets::NotesController do expect { post :create, params: request_params }.to change { Note.count }.by(1) end - it_behaves_like 'request exceeding rate limit', :clean_gitlab_redis_cache do - let(:params) { request_params } - let(:request_full_path) { snippet_notes_path(private_snippet) } - let(:user) { private_snippet.author } + it_behaves_like 'create notes request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { private_snippet.author } + + def request + post :create, params: request_params + end end end end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index edb412cbb9c..9bd6691bdb2 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -88,7 +88,8 @@ RSpec.describe 'Database schema' do users_star_projects: %w[user_id], vulnerability_identifiers: %w[external_id], vulnerability_scanners: %w[external_id], - security_scans: %w[pipeline_id] # foreign key is not added as ci_pipeline table will be moved into different db soon + security_scans: %w[pipeline_id], # foreign key is not added as ci_pipeline table will be moved into different db soon + vulnerability_reads: %w[cluster_agent_id] }.with_indifferent_access.freeze context 'for table' do diff --git a/spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb b/spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb deleted file mode 100644 index 815aaf7c397..00000000000 --- a/spec/experiments/change_continuous_onboarding_link_urls_experiment_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ChangeContinuousOnboardingLinkUrlsExperiment, :snowplow do - before do - stub_experiments(change_continuous_onboarding_link_urls: 'control') - end - - describe '#track' do - context 'when no namespace has been set' do - it 'tracks the action as normal' do - subject.track(:some_action) - - expect_snowplow_event( - category: subject.name, - action: 'some_action', - namespace: nil, - context: [ - { - schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', - data: an_instance_of(Hash) - } - ] - ) - end - end - - context 'when a namespace has been set' do - let_it_be(:namespace) { create(:namespace) } - - before do - subject.namespace = namespace - end - - it 'tracks the action and merges the namespace into the event args' do - subject.track(:some_action) - - expect_snowplow_event( - category: subject.name, - action: 'some_action', - namespace: namespace, - context: [ - { - schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0', - data: an_instance_of(Hash) - } - ] - ) - end - end - end -end diff --git a/spec/experiments/new_project_sast_enabled_experiment_spec.rb b/spec/experiments/new_project_sast_enabled_experiment_spec.rb index 38f58c01973..041e5dfa469 100644 --- a/spec/experiments/new_project_sast_enabled_experiment_spec.rb +++ b/spec/experiments/new_project_sast_enabled_experiment_spec.rb @@ -4,7 +4,12 @@ require 'spec_helper' RSpec.describe NewProjectSastEnabledExperiment do it "defines the expected behaviors and variants" do - expect(subject.behaviors.keys).to match_array(%w[control candidate free_indicator unchecked_candidate]) + expect(subject.variant_names).to match_array([ + :candidate, + :free_indicator, + :unchecked_candidate, + :unchecked_free_indicator + ]) end it "publishes to the database" do diff --git a/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb b/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb new file mode 100644 index 00000000000..87417fe1637 --- /dev/null +++ b/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RequireVerificationForNamespaceCreationExperiment, :experiment do + subject(:experiment) { described_class.new(user: user) } + + let_it_be(:user) { create(:user) } + + describe '#candidate?' do + context 'when experiment subject is candidate' do + before do + stub_experiments(require_verification_for_namespace_creation: :candidate) + end + + it 'returns true' do + expect(experiment.candidate?).to eq(true) + end + end + + context 'when experiment subject is control' do + before do + stub_experiments(require_verification_for_namespace_creation: :control) + end + + it 'returns false' do + expect(experiment.candidate?).to eq(false) + end + end + end + + describe '#record_conversion' do + let_it_be(:namespace) { create(:namespace) } + + context 'when should_track? is false' do + before do + allow(experiment).to receive(:should_track?).and_return(false) + end + + it 'does not record a conversion event' do + expect(experiment.publish_to_database).to be_nil + expect(experiment.record_conversion(namespace)).to be_nil + end + end + + context 'when should_track? is true' do + before do + allow(experiment).to receive(:should_track?).and_return(true) + end + + it 'records a conversion event' do + experiment_subject = experiment.publish_to_database + + expect { experiment.record_conversion(namespace) }.to change { experiment_subject.reload.converted_at }.from(nil) + .and change { experiment_subject.context }.to include('namespace_id' => namespace.id) + end + end + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index e6eaebc9b6b..011021f6320 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -338,6 +338,10 @@ FactoryBot.define do running runner factory: :ci_runner + + after(:create) do |build| + build.create_runtime_metadata! + end end trait :artifacts do @@ -596,6 +600,11 @@ FactoryBot.define do failure_reason { 13 } end + trait :deployment_rejected do + failed + failure_reason { 22 } + end + trait :with_runner_session do after(:build) do |build| build.build_runner_session(url: 'https://localhost') diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 223de873a04..e6eec280ed0 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -10,6 +10,10 @@ FactoryBot.define do expire_at { Date.yesterday } end + trait :locked do + locked { Ci::JobArtifact.lockeds[:artifacts_locked] } + end + trait :remote_store do file_store { JobArtifactUploader::Store::REMOTE} end diff --git a/spec/factories/ci/pipeline_message.rb b/spec/factories/ci/pipeline_message.rb new file mode 100644 index 00000000000..71fac24922d --- /dev/null +++ b/spec/factories/ci/pipeline_message.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_pipeline_message, class: 'Ci::PipelineMessage' do + pipeline factory: :ci_pipeline + content { 'warning' } + severity { 1 } + end +end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index b2c1eff6fbd..122af139985 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -87,6 +87,10 @@ FactoryBot.define do locked { Ci::Pipeline.lockeds[:unlocked] } end + trait :artifacts_locked do + locked { Ci::Pipeline.lockeds[:artifacts_locked] } + end + trait :protected do add_attribute(:protected) { true } end diff --git a/spec/factories/ci/secure_files.rb b/spec/factories/ci/secure_files.rb new file mode 100644 index 00000000000..9198ea61d14 --- /dev/null +++ b/spec/factories/ci/secure_files.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_secure_file, class: 'Ci::SecureFile' do + name { 'filename' } + file { fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks', 'application/octet-stream') } + checksum { 'foo1234' } + project + end +end diff --git a/spec/factories/clusters/agent_tokens.rb b/spec/factories/clusters/agent_tokens.rb index c49d197c3cd..03f765123db 100644 --- a/spec/factories/clusters/agent_tokens.rb +++ b/spec/factories/clusters/agent_tokens.rb @@ -7,5 +7,9 @@ FactoryBot.define do token_encrypted { Gitlab::CryptoHelper.aes256_gcm_encrypt(SecureRandom.hex(50)) } sequence(:name) { |n| "agent-token-#{n}" } + + trait :revoked do + status { :revoked } + end end end diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index 29197768ec0..10fa739acc1 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -118,7 +118,6 @@ FactoryBot.define do end factory :clusters_applications_runner, class: 'Clusters::Applications::Runner' do - runner factory: %i(ci_runner) cluster factory: %i(cluster with_installed_helm provided_by_gcp) end diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb index 836ee87e4d7..afa6c61116a 100644 --- a/spec/factories/dependency_proxy.rb +++ b/spec/factories/dependency_proxy.rb @@ -8,8 +8,8 @@ FactoryBot.define do file_name { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz' } status { :default } - trait :expired do - status { :expired } + trait :pending_destruction do + status { :pending_destruction } end end @@ -22,8 +22,8 @@ FactoryBot.define do content_type { 'application/vnd.docker.distribution.manifest.v2+json' } status { :default } - trait :expired do - status { :expired } + trait :pending_destruction do + status { :pending_destruction } end end end diff --git a/spec/factories/group/crm_settings.rb b/spec/factories/group/crm_settings.rb new file mode 100644 index 00000000000..06a31fd69c0 --- /dev/null +++ b/spec/factories/group/crm_settings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :crm_settings, class: 'Group::CrmSettings' do + group + end +end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 859f381e4c1..152ae061605 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -112,5 +112,11 @@ FactoryBot.define do ) end end + + trait :crm_enabled do + after(:create) do |group| + create(:crm_settings, group: group, enabled: true) + end + end end end diff --git a/spec/factories/incident_management/issuable_escalation_statuses.rb b/spec/factories/incident_management/issuable_escalation_statuses.rb index 54d0887f386..0486e0481bf 100644 --- a/spec/factories/incident_management/issuable_escalation_statuses.rb +++ b/spec/factories/incident_management/issuable_escalation_statuses.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :incident_management_issuable_escalation_status, class: 'IncidentManagement::IssuableEscalationStatus' do - issue + association :issue, factory: :incident triggered trait :triggered do diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 76415f82ed0..f3a00ac083a 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :integration, aliases: [:service] do + factory :integration do project type { 'Integration' } end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index a9a9416c48b..f0cef41db69 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -42,4 +42,6 @@ FactoryBot.define do factory :group_label, traits: [:base_label] do group end + + factory :admin_label, traits: [:base_label], class: 'Label' end diff --git a/spec/factories/namespaces.rb b/spec/factories/namespaces.rb index 2b3dabc07d8..e88bb634898 100644 --- a/spec/factories/namespaces.rb +++ b/spec/factories/namespaces.rb @@ -11,6 +11,14 @@ FactoryBot.define do owner { association(:user, strategy: :build, namespace: instance, username: path) } + after(:create) do |namespace, evaluator| + # simulating ::Namespaces::ProcessSyncEventsWorker because most tests don't run Sidekiq inline + # Note: we need to get refreshed `traversal_ids` it is updated via SQL query + # in `Namespaces::Traversal::Linear#sync_traversal_ids` (see the NOTE in that method). + # We cannot use `.reload` because it cleans other on-the-fly attributes. + namespace.create_ci_namespace_mirror!(traversal_ids: Namespace.find(namespace.id).traversal_ids) unless namespace.ci_namespace_mirror + end + trait :with_aggregation_schedule do after(:create) do |namespace| create(:namespace_aggregation_schedules, namespace: namespace) diff --git a/spec/factories/packages/package_files.rb b/spec/factories/packages/package_files.rb index 845fd882beb..5eac0036b91 100644 --- a/spec/factories/packages/package_files.rb +++ b/spec/factories/packages/package_files.rb @@ -6,6 +6,8 @@ FactoryBot.define do file_name { 'somefile.txt' } + status { :default } + transient do file_fixture { 'spec/fixtures/packages/conan/recipe_files/conanfile.py' } end @@ -14,6 +16,10 @@ FactoryBot.define do package_file.file = fixture_file_upload(evaluator.file_fixture) end + trait :pending_destruction do + status { :pending_destruction } + end + factory :conan_package_file do package { association(:conan_package, without_package_files: true) } diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 981f10e8260..c345fa0c8b4 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -49,6 +49,8 @@ FactoryBot.define do forward_deployment_enabled { nil } restrict_user_defined_variables { nil } ci_job_token_scope_enabled { nil } + runner_token_expiration_interval { nil } + runner_token_expiration_interval_human_readable { nil } end after(:build) do |project, evaluator| @@ -92,6 +94,8 @@ FactoryBot.define do project.keep_latest_artifact = evaluator.keep_latest_artifact unless evaluator.keep_latest_artifact.nil? project.restrict_user_defined_variables = evaluator.restrict_user_defined_variables unless evaluator.restrict_user_defined_variables.nil? project.ci_job_token_scope_enabled = evaluator.ci_job_token_scope_enabled unless evaluator.ci_job_token_scope_enabled.nil? + project.runner_token_expiration_interval = evaluator.runner_token_expiration_interval unless evaluator.runner_token_expiration_interval.nil? + project.runner_token_expiration_interval_human_readable = evaluator.runner_token_expiration_interval_human_readable unless evaluator.runner_token_expiration_interval_human_readable.nil? if evaluator.import_status import_state = project.import_state || project.build_import_state @@ -101,6 +105,9 @@ FactoryBot.define do import_state.last_error = evaluator.import_last_error import_state.save! end + + # simulating ::Projects::ProcessSyncEventsWorker because most tests don't run Sidekiq inline + project.create_ci_project_mirror!(namespace_id: project.namespace_id) unless project.ci_project_mirror end trait :public do diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb index fc1f5d71f39..f00d1f8b808 100644 --- a/spec/factories/usage_data.rb +++ b/spec/factories/usage_data.rb @@ -19,16 +19,16 @@ FactoryBot.define do create(:jira_import_state, :finished, project: projects[1], label: jira_label, imported_issues_count: 3) create(:jira_import_state, :scheduled, project: projects[1], label: jira_label) create(:prometheus_integration, project: projects[1]) - create(:service, project: projects[1], type: 'JenkinsService', active: true) - create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true) - create(:service, project: projects[1], type: 'SlackService', active: true) - create(:service, project: projects[2], type: 'SlackService', active: true) - create(:service, project: projects[2], type: 'MattermostService', active: false) - create(:service, group: group, project: nil, type: 'MattermostService', active: true) - mattermost_instance = create(:service, :instance, type: 'MattermostService', active: true) - create(:service, project: projects[1], type: 'MattermostService', active: true, inherit_from_id: mattermost_instance.id) - create(:service, group: group, project: nil, type: 'SlackService', active: true, inherit_from_id: mattermost_instance.id) - create(:service, project: projects[2], type: 'CustomIssueTrackerService', active: true) + create(:integration, project: projects[1], type: 'JenkinsService', active: true) + create(:integration, project: projects[0], type: 'SlackSlashCommandsService', active: true) + create(:integration, project: projects[1], type: 'SlackService', active: true) + create(:integration, project: projects[2], type: 'SlackService', active: true) + create(:integration, project: projects[2], type: 'MattermostService', active: false) + create(:integration, group: group, project: nil, type: 'MattermostService', active: true) + mattermost_instance = create(:integration, :instance, type: 'MattermostService', active: true) + create(:integration, project: projects[1], type: 'MattermostService', active: true, inherit_from_id: mattermost_instance.id) + create(:integration, group: group, project: nil, type: 'SlackService', active: true, inherit_from_id: mattermost_instance.id) + create(:integration, project: projects[2], type: 'CustomIssueTrackerService', active: true) create(:project_error_tracking_setting, project: projects[0]) create(:project_error_tracking_setting, project: projects[1], enabled: false) alert_bot_issues = create_list(:incident, 2, project: projects[0], author: User.alert_bot) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 8aa9654956e..5f325717ec5 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -5,7 +5,7 @@ FactoryBot.define do email { generate(:email) } name { generate(:name) } username { generate(:username) } - password { "12345678" } + password { Gitlab::Password.test_default } role { 'software_developer' } confirmed_at { Time.now } confirmation_token { nil } diff --git a/spec/factories/wikis.rb b/spec/factories/wikis.rb index 05f6fb0de58..a357f4b448d 100644 --- a/spec/factories/wikis.rb +++ b/spec/factories/wikis.rb @@ -4,7 +4,7 @@ FactoryBot.define do factory :wiki do transient do container { association(:project) } - user { container.default_owner || association(:user) } + user { container.first_owner || association(:user) } end initialize_with { Wiki.for_container(container, user) } diff --git a/spec/factories/work_item/work_item_types.rb b/spec/factories/work_items/work_item_types.rb index 1c586aab59b..0920b36bcbd 100644 --- a/spec/factories/work_item/work_item_types.rb +++ b/spec/factories/work_items/work_item_types.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true FactoryBot.define do - factory :work_item_type, class: 'WorkItem::Type' do + factory :work_item_type, class: 'WorkItems::Type' do namespace name { generate(:work_item_type_name) } - base_type { WorkItem::Type.base_types[:issue] } + base_type { WorkItems::Type.base_types[:issue] } icon_name { 'issue-type-issue' } initialize_with do @@ -13,9 +13,9 @@ FactoryBot.define do # Expect base_types to exist on the DB if type_base_attributes.slice(:namespace, :namespace_id).compact.empty? - WorkItem::Type.find_or_initialize_by(type_base_attributes).tap { |type| type.assign_attributes(attributes) } + WorkItems::Type.find_or_initialize_by(type_base_attributes).tap { |type| type.assign_attributes(attributes) } else - WorkItem::Type.new(attributes) + WorkItems::Type.new(attributes) end end @@ -24,17 +24,17 @@ FactoryBot.define do end trait :incident do - base_type { WorkItem::Type.base_types[:incident] } + base_type { WorkItems::Type.base_types[:incident] } icon_name { 'issue-type-incident' } end trait :test_case do - base_type { WorkItem::Type.base_types[:test_case] } + base_type { WorkItems::Type.base_types[:test_case] } icon_name { 'issue-type-test-case' } end trait :requirement do - base_type { WorkItem::Type.base_types[:requirement] } + base_type { WorkItems::Type.base_types[:requirement] } icon_name { 'issue-type-requirements' } end end diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb index 9b74aa2ac5a..88b8fcd8d5e 100644 --- a/spec/features/admin/admin_deploy_keys_spec.rb +++ b/spec/features/admin/admin_deploy_keys_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'admin deploy keys' do +RSpec.describe 'admin deploy keys', :js do include Spec::Support::Helpers::ModalHelpers let_it_be(:admin) { create(:admin) } @@ -15,112 +15,81 @@ RSpec.describe 'admin deploy keys' do gitlab_enable_admin_mode_sign_in(admin) end - shared_examples 'renders deploy keys correctly' do - it 'show all public deploy keys' do - visit admin_deploy_keys_path + it 'show all public deploy keys' do + visit admin_deploy_keys_path - page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do - expect(page).to have_content(deploy_key.title) - expect(page).to have_content(another_deploy_key.title) - end + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do + expect(page).to have_content(deploy_key.title) + expect(page).to have_content(another_deploy_key.title) end + end - it 'shows all the projects the deploy key has write access' do - write_key = create(:deploy_keys_project, :write_access, deploy_key: deploy_key) + it 'shows all the projects the deploy key has write access' do + write_key = create(:deploy_keys_project, :write_access, deploy_key: deploy_key) - visit admin_deploy_keys_path + visit admin_deploy_keys_path - page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do - expect(page).to have_content(write_key.project.full_name) - end + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do + expect(page).to have_content(write_key.project.full_name) end + end - describe 'create a new deploy key' do - let(:new_ssh_key) { attributes_for(:key)[:key] } - - before do - visit admin_deploy_keys_path - click_link 'New deploy key' - end - - it 'creates a new deploy key' do - fill_in 'deploy_key_title', with: 'laptop' - fill_in 'deploy_key_key', with: new_ssh_key - click_button 'Create' - - expect(current_path).to eq admin_deploy_keys_path + describe 'create a new deploy key' do + let(:new_ssh_key) { attributes_for(:key)[:key] } - page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do - expect(page).to have_content('laptop') - end - end + before do + visit admin_deploy_keys_path + click_link 'New deploy key' end - describe 'update an existing deploy key' do - before do - visit admin_deploy_keys_path - page.within('tr', text: deploy_key.title) do - click_link(_('Edit deploy key')) - end - end + it 'creates a new deploy key' do + fill_in 'deploy_key_title', with: 'laptop' + fill_in 'deploy_key_key', with: new_ssh_key + click_button 'Create' - it 'updates an existing deploy key' do - fill_in 'deploy_key_title', with: 'new-title' - click_button 'Save changes' + expect(current_path).to eq admin_deploy_keys_path - expect(current_path).to eq admin_deploy_keys_path - - page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do - expect(page).to have_content('new-title') - end + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do + expect(page).to have_content('laptop') end end end - context 'when `admin_deploy_keys_vue` feature flag is enabled', :js do - it_behaves_like 'renders deploy keys correctly' - - describe 'remove an existing deploy key' do - before do - visit admin_deploy_keys_path + describe 'update an existing deploy key' do + before do + visit admin_deploy_keys_path + page.within('tr', text: deploy_key.title) do + click_link(_('Edit deploy key')) end + end - it 'removes an existing deploy key' do - accept_gl_confirm('Are you sure you want to delete this deploy key?', button_text: 'Delete') do - page.within('tr', text: deploy_key.title) do - click_button _('Delete deploy key') - end - end + it 'updates an existing deploy key' do + fill_in 'deploy_key_title', with: 'new-title' + click_button 'Save changes' - expect(current_path).to eq admin_deploy_keys_path - page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do - expect(page).not_to have_content(deploy_key.title) - end + expect(current_path).to eq admin_deploy_keys_path + + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do + expect(page).to have_content('new-title') end end end - context 'when `admin_deploy_keys_vue` feature flag is disabled' do + describe 'remove an existing deploy key' do before do - stub_feature_flags(admin_deploy_keys_vue: false) + visit admin_deploy_keys_path end - it_behaves_like 'renders deploy keys correctly' - - describe 'remove an existing deploy key' do - before do - visit admin_deploy_keys_path - end - - it 'removes an existing deploy key' do + it 'removes an existing deploy key' do + accept_gl_confirm('Are you sure you want to delete this deploy key?', button_text: 'Delete') do page.within('tr', text: deploy_key.title) do - click_link _('Remove deploy key') + click_button _('Delete deploy key') end + end - expect(current_path).to eq admin_deploy_keys_path - page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do - expect(page).not_to have_content(deploy_key.title) - end + expect(current_path).to eq admin_deploy_keys_path + page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do + expect(page).not_to have_content(deploy_key.title) end end end diff --git a/spec/features/admin/admin_labels_spec.rb b/spec/features/admin/admin_labels_spec.rb index 86d60b5d483..ba0870a53ae 100644 --- a/spec/features/admin/admin_labels_spec.rb +++ b/spec/features/admin/admin_labels_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'admin issues labels' do + include Spec::Support::Helpers::ModalHelpers + let!(:bug_label) { Label.create!(title: 'bug', template: true) } let!(:feature_label) { Label.create!(title: 'feature', template: true) } @@ -59,7 +61,7 @@ RSpec.describe 'admin issues labels' do it 'creates new label' do fill_in 'Title', with: 'support' fill_in 'Background color', with: '#F95610' - click_button 'Save' + click_button 'Create label' page.within '.manage-labels-list' do expect(page).to have_content('support') @@ -69,7 +71,7 @@ RSpec.describe 'admin issues labels' do it 'does not creates label with invalid color' do fill_in 'Title', with: 'support' fill_in 'Background color', with: '#12' - click_button 'Save' + click_button 'Create label' page.within '.label-form' do expect(page).to have_content('Color must be a valid color code') @@ -79,7 +81,7 @@ RSpec.describe 'admin issues labels' do it 'does not creates label if label already exists' do fill_in 'Title', with: 'bug' fill_in 'Background color', with: '#F95610' - click_button 'Save' + click_button 'Create label' page.within '.label-form' do expect(page).to have_content 'Title has already been taken' @@ -93,11 +95,25 @@ RSpec.describe 'admin issues labels' do fill_in 'Title', with: 'fix' fill_in 'Background color', with: '#F15610' - click_button 'Save' + click_button 'Save changes' page.within '.manage-labels-list' do expect(page).to have_content('fix') end end + + it 'allows user to delete label', :js do + visit edit_admin_label_path(bug_label) + + click_button 'Delete' + + within_modal do + expect(page).to have_content("#{bug_label.title} will be permanently deleted. This cannot be undone.") + + click_link 'Delete label' + end + + expect(page).to have_content('Label was removed') + end end end diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index cc2d36221dc..ceb91b86876 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -21,12 +21,16 @@ RSpec.describe "Admin Runners" do context "when there are runners" do it 'has all necessary texts' do - create(:ci_runner, :instance, contacted_at: Time.now) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.now) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago) visit admin_runners_path expect(page).to have_text "Register an instance runner" - expect(page).to have_text "Online Runners 1" + expect(page).to have_text "Online runners 1" + expect(page).to have_text "Offline runners 2" + expect(page).to have_text "Stale runners 1" end it 'with an instance runner shows an instance badge' do @@ -131,6 +135,9 @@ RSpec.describe "Admin Runners" do it 'shows correct runner when description matches' do input_filtered_search_keys('runner-foo') + expect(page).to have_link('All 1') + expect(page).to have_link('Instance 1') + expect(page).to have_content("runner-foo") expect(page).not_to have_content("runner-bar") end @@ -138,71 +145,78 @@ RSpec.describe "Admin Runners" do it 'shows no runner when description does not match' do input_filtered_search_keys('runner-baz') + expect(page).to have_link('All 0') + expect(page).to have_link('Instance 0') + expect(page).to have_text 'No runners found' end end describe 'filter by status' do - it 'shows correct runner when status matches' do - create(:ci_runner, :instance, description: 'runner-active', active: true) - create(:ci_runner, :instance, description: 'runner-paused', active: false) + let!(:never_contacted) { create(:ci_runner, :instance, description: 'runner-never-contacted', contacted_at: nil) } + + before do + create(:ci_runner, :instance, description: 'runner-1', contacted_at: Time.now) + create(:ci_runner, :instance, description: 'runner-2', contacted_at: Time.now) + create(:ci_runner, :instance, description: 'runner-paused', active: false, contacted_at: Time.now) visit admin_runners_path + end - expect(page).to have_content 'runner-active' + it 'shows all runners' do + expect(page).to have_content 'runner-1' + expect(page).to have_content 'runner-2' expect(page).to have_content 'runner-paused' + expect(page).to have_content 'runner-never-contacted' + expect(page).to have_link('All 4') + end + + it 'shows correct runner when status matches' do input_filtered_search_filter_is_only('Status', 'Active') - expect(page).to have_content 'runner-active' + expect(page).to have_link('All 3') + + expect(page).to have_content 'runner-1' + expect(page).to have_content 'runner-2' + expect(page).to have_content 'runner-never-contacted' expect(page).not_to have_content 'runner-paused' end it 'shows no runner when status does not match' do - create(:ci_runner, :instance, description: 'runner-active', active: true) - create(:ci_runner, :instance, description: 'runner-paused', active: false) + input_filtered_search_filter_is_only('Status', 'Stale') - visit admin_runners_path - - input_filtered_search_filter_is_only('Status', 'Online') - - expect(page).not_to have_content 'runner-active' - expect(page).not_to have_content 'runner-paused' + expect(page).to have_link('All 0') expect(page).to have_text 'No runners found' end it 'shows correct runner when status is selected and search term is entered' do - create(:ci_runner, :instance, description: 'runner-a-1', active: true) - create(:ci_runner, :instance, description: 'runner-a-2', active: false) - create(:ci_runner, :instance, description: 'runner-b-1', active: true) - - visit admin_runners_path - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_keys('runner-1') - expect(page).to have_content 'runner-a-1' - expect(page).to have_content 'runner-b-1' - expect(page).not_to have_content 'runner-a-2' - - input_filtered_search_keys('runner-a') + expect(page).to have_link('All 1') - expect(page).to have_content 'runner-a-1' - expect(page).not_to have_content 'runner-b-1' - expect(page).not_to have_content 'runner-a-2' + expect(page).to have_content 'runner-1' + expect(page).not_to have_content 'runner-2' + expect(page).not_to have_content 'runner-never-contacted' + expect(page).not_to have_content 'runner-paused' end - it 'shows correct runner when type is selected and search term is entered' do - create(:ci_runner, :instance, description: 'runner-connected', contacted_at: Time.now) - create(:ci_runner, :instance, description: 'runner-not-connected', contacted_at: nil) + it 'shows correct runner when status filter is entered' do + # use the string "Never" to avoid using space and trigger an early selection + input_filtered_search_filter_is_only('Status', 'Never') - visit admin_runners_path + expect(page).to have_link('All 1') - # use the string "Not" to avoid using space and trigger an early selection - input_filtered_search_filter_is_only('Status', 'Not') + expect(page).not_to have_content 'runner-1' + expect(page).not_to have_content 'runner-2' + expect(page).not_to have_content 'runner-paused' + expect(page).to have_content 'runner-never-contacted' - expect(page).not_to have_content 'runner-connected' - expect(page).to have_content 'runner-not-connected' + within "[data-testid='runner-row-#{never_contacted.id}']" do + expect(page).to have_selector '.badge', text: 'never contacted' + end end end @@ -215,6 +229,10 @@ RSpec.describe "Admin Runners" do it '"All" tab is selected by default' do visit admin_runners_path + expect(page).to have_link('All 2') + expect(page).to have_link('Group 1') + expect(page).to have_link('Project 1') + page.within('[data-testid="runner-type-tabs"]') do expect(page).to have_link('All', class: 'active') end @@ -373,9 +391,28 @@ RSpec.describe "Admin Runners" do it 'has all necessary texts including no runner message' do expect(page).to have_text "Register an instance runner" - expect(page).to have_text "Online Runners 0" + + expect(page).to have_text "Online runners 0" + expect(page).to have_text "Offline runners 0" + expect(page).to have_text "Stale runners 0" + expect(page).to have_text 'No runners found' end + + it 'shows tabs with total counts equal to 0' do + expect(page).to have_link('All 0') + expect(page).to have_link('Instance 0') + expect(page).to have_link('Group 0') + expect(page).to have_link('Project 0') + end + end + + context "when visiting outdated URLs" do + it 'updates NOT_CONNECTED runner status to NEVER_CONNECTED' do + visit admin_runners_path('status[]': 'NOT_CONNECTED') + + expect(page).to have_current_path(admin_runners_path('status[]': 'NEVER_CONTACTED') ) + end end describe 'runners registration' do @@ -422,7 +459,9 @@ RSpec.describe "Admin Runners" do before do click_on 'Reset registration token' - page.accept_alert + within_modal do + click_button('OK', match: :first) + end wait_for_requests end @@ -437,26 +476,29 @@ RSpec.describe "Admin Runners" do end end - describe "Runner show page" do + describe "Runner edit page" do let(:runner) { create(:ci_runner) } before do @project1 = create(:project) @project2 = create(:project) - visit admin_runner_path(runner) + visit edit_admin_runner_path(runner) + + wait_for_requests end describe 'runner page breadcrumbs' do - it 'contains the current runner token' do + it 'contains the current runner id and token' do page.within '[data-testid="breadcrumb-links"]' do - expect(page.find('h2')).to have_content(runner.short_sha) + expect(page).to have_link("##{runner.id} (#{runner.short_sha})") + expect(page.find('h2')).to have_content("Edit") end end end - describe 'runner page title', :js do - it 'contains the runner id' do - expect(find('.page-title')).to have_content("Runner ##{runner.id}") + describe 'runner header', :js do + it 'contains the runner status, type and id' do + expect(page).to have_content("never contacted shared Runner ##{runner.id} created") end end @@ -498,7 +540,7 @@ RSpec.describe "Admin Runners" do let(:runner) { create(:ci_runner, :project, projects: [@project1]) } before do - visit admin_runner_path(runner) + visit edit_admin_runner_path(runner) end it_behaves_like 'assignable runner' @@ -508,7 +550,7 @@ RSpec.describe "Admin Runners" do let(:runner) { create(:ci_runner, :project, projects: [@project1], locked: true) } before do - visit admin_runner_path(runner) + visit edit_admin_runner_path(runner) end it_behaves_like 'assignable runner' @@ -519,7 +561,7 @@ RSpec.describe "Admin Runners" do before do @project1.destroy! - visit admin_runner_path(runner) + visit edit_admin_runner_path(runner) end it_behaves_like 'assignable runner' @@ -530,7 +572,7 @@ RSpec.describe "Admin Runners" do let(:runner) { create(:ci_runner, :project, projects: [@project1]) } before do - visit admin_runner_path(runner) + visit edit_admin_runner_path(runner) end it 'removed specific runner from project' do @@ -567,6 +609,8 @@ RSpec.describe "Admin Runners" do page.find('input').send_keys(search_term) click_on 'Search' end + + wait_for_requests end def input_filtered_search_filter_is_only(filter, value) @@ -583,5 +627,7 @@ RSpec.describe "Admin Runners" do click_on 'Search' end + + wait_for_requests end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 29323c604ef..e136ab41966 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -275,7 +275,7 @@ RSpec.describe 'Admin updates settings' do it 'enable hiding third party offers' do page.within('.as-third-party-offers') do - check 'Do not display offers from third parties' + check 'Do not display content for customer experience improvement and offers from third parties' click_button 'Save changes' end @@ -530,6 +530,7 @@ RSpec.describe 'Admin updates settings' do it 'loads usage ping payload on click', :js do stub_usage_data_connections + stub_database_flavor_check page.within('#js-usage-settings') do expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 2b627707ff2..95e3f5c70e5 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -57,4 +57,33 @@ RSpec.describe "Admin::Users" do expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0") end end + + describe 'prompt user about registration features' do + let(:message) { s_("RegistrationFeatures|Want to %{feature_title} for free?") % { feature_title: s_('RegistrationFeatures|send emails to users') } } + + it 'does not render registration features CTA when service ping is enabled' do + stub_application_setting(usage_ping_enabled: true) + + visit admin_users_path + + expect(page).not_to have_content(message) + end + + context 'with no license and service ping disabled' do + before do + stub_application_setting(usage_ping_enabled: false) + + if Gitlab.ee? + allow(License).to receive(:current).and_return(nil) + end + end + + it 'renders registration features CTA' do + visit admin_users_path + + expect(page).to have_content(message) + expect(page).to have_link(s_('RegistrationFeatures|Registration Features Program')) + end + end + end end diff --git a/spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb b/spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb index 22a27b33671..793a5bced00 100644 --- a/spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb +++ b/spec/features/admin/integrations/user_activates_mattermost_slash_command_spec.rb @@ -19,4 +19,19 @@ RSpec.describe 'User activates the instance-level Mattermost Slash Command integ expect(page).to have_link('Settings', href: edit_path) expect(page).to have_link('Projects using custom settings', href: overrides_path) end + + it 'does not render integration form element' do + expect(page).not_to have_selector('[data-testid="integration-form"]') + end + + context 'when `vue_integration_form` feature flag is disabled' do + before do + stub_feature_flags(vue_integration_form: false) + visit_instance_integration('Mattermost slash commands') + end + + it 'renders integration form element' do + expect(page).to have_selector('[data-testid="integration-form"]') + end + end end diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb index ae940fecabe..0d053329627 100644 --- a/spec/features/admin/users/user_spec.rb +++ b/spec/features/admin/users/user_spec.rb @@ -125,6 +125,26 @@ RSpec.describe 'Admin::Users::User' do end end + context 'when a user is locked', time_travel_to: '2020-02-02 10:30:45 -0700' do + let_it_be(:locked_user) { create(:user, locked_at: DateTime.parse('2020-02-02 10:30:00 -0700')) } + + before do + visit admin_user_path(locked_user) + end + + it "displays `(Locked)` next to user's name" do + expect(page).to have_content("#{locked_user.name} (Locked)") + end + + it 'allows a user to be unlocked from the `User administration dropdown', :js do + accept_gl_confirm("Unlock user #{locked_user.name}?", button_text: 'Unlock') do + click_action_in_user_dropdown(locked_user.id, 'Unlock') + end + + expect(page).not_to have_content("#{locked_user.name} (Locked)") + end + end + describe 'Impersonation' do let_it_be(:another_user) { create(:user) } diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb index fa943245fcb..473f51370b3 100644 --- a/spec/features/admin/users/users_spec.rb +++ b/spec/features/admin/users/users_spec.rb @@ -462,9 +462,9 @@ RSpec.describe 'Admin::Users' do visit projects_admin_user_path(user) end - it 'lists group projects' do + it 'lists groups' do within(:css, '.gl-mb-3 + .card') do - expect(page).to have_content 'Group projects' + expect(page).to have_content 'Groups' expect(page).to have_link group.name, href: admin_group_path(group) end end diff --git a/spec/features/boards/board_filters_spec.rb b/spec/features/boards/board_filters_spec.rb index 25e474bb676..49375e4b37b 100644 --- a/spec/features/boards/board_filters_spec.rb +++ b/spec/features/boards/board_filters_spec.rb @@ -34,7 +34,9 @@ RSpec.describe 'Issue board filters', :js do it 'and submit one as filter', :aggregate_failures do expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) - expect_filtered_search_dropdown_results(filter_dropdown, 3) + wait_for_requests + + expect_filtered_search_dropdown_results(filter_dropdown, 4) click_on user.username filter_submit.click diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 2f21961d1fc..d25cddea902 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -528,7 +528,7 @@ RSpec.describe 'Project issue boards', :js do end it 'does not allow dragging' do - expect(page).not_to have_selector('.user-can-drag') + expect(page).not_to have_selector('.gl-cursor-grab') end end diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 0bb8e0bcdc0..0e914ae19d1 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -6,9 +6,11 @@ RSpec.describe 'Project issue boards sidebar', :js do include BoardHelpers let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :public) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :public, namespace: group) } let_it_be(:board) { create(:board, project: project) } - let_it_be(:list) { create(:list, board: board, position: 0) } + let_it_be(:label) { create(:label, project: project, name: 'Label') } + let_it_be(:list) { create(:list, board: board, label: label, position: 0) } let_it_be(:issue, reload: true) { create(:issue, project: project, relative_position: 1) } diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 4378e88f7c1..e600a99e3b6 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -30,10 +30,10 @@ RSpec.describe 'Commits' do project.add_reporter(user) end - describe 'Commit builds with jobs_tab_feature flag off' do + describe 'Commit builds with jobs_tab_vue feature flag off' do before do stub_feature_flags(jobs_tab_vue: false) - visit pipeline_path(pipeline) + visit builds_project_pipeline_path(project, pipeline) end it { expect(page).to have_content pipeline.sha[0..7] } @@ -45,6 +45,23 @@ RSpec.describe 'Commits' do end end end + + describe 'Commit builds with jobs_tab_vue feature flag on', :js do + before do + visit builds_project_pipeline_path(project, pipeline) + + wait_for_requests + end + + it { expect(page).to have_content pipeline.sha[0..7] } + + it 'contains generic commit status build' do + page.within('[data-testid="jobs-tab-table"]') do + expect(page).to have_content "##{status.id}" # build id + expect(page).to have_content 'generic' # build name + end + end + end end context 'commit status is Ci Build' do @@ -103,6 +120,18 @@ RSpec.describe 'Commits' do end end + context 'Download artifacts with jobs_tab_vue feature flag on', :js do + before do + create(:ci_job_artifact, :archive, file: artifacts_file, job: build) + end + + it do + visit builds_project_pipeline_path(project, pipeline) + wait_for_requests + expect(page).to have_link('Download artifacts', href: download_project_job_artifacts_path(project, build, file_type: :archive)) + end + end + describe 'Cancel all builds' do it 'cancels commit', :js, :sidekiq_might_not_need_inline do visit pipeline_path(pipeline) @@ -141,6 +170,27 @@ RSpec.describe 'Commits' do end end + context "when logged as reporter and with jobs_tab_vue feature flag on", :js do + before do + project.add_reporter(user) + create(:ci_job_artifact, :archive, file: artifacts_file, job: build) + visit builds_project_pipeline_path(project, pipeline) + wait_for_requests + end + + it 'renders header' do + expect(page).to have_content pipeline.sha[0..7] + expect(page).to have_content pipeline.git_commit_message.gsub!(/\s+/, ' ') + expect(page).to have_content pipeline.user.name + expect(page).not_to have_link('Cancel running') + expect(page).not_to have_link('Retry') + end + + it do + expect(page).to have_link('Download artifacts') + end + end + context 'when accessing internal project with disallowed access', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/299575' do before do project.update!( diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index a9fb6a2ae7e..64181041be5 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -71,7 +71,7 @@ RSpec.describe 'Dashboard Issues' do find('#select2-drop-mask', visible: false) execute_script("$('#select2-drop-mask').remove();") - find('.new-project-item-link').click + find('.js-new-project-item-link').click expect(page).to have_current_path("#{project_path}/-/issues/new") diff --git a/spec/features/dashboard/milestones_spec.rb b/spec/features/dashboard/milestones_spec.rb index 1ba16bf879a..9758454ab61 100644 --- a/spec/features/dashboard/milestones_spec.rb +++ b/spec/features/dashboard/milestones_spec.rb @@ -41,7 +41,7 @@ RSpec.describe 'Dashboard > Milestones' do first('.select2-result-label').click end - find('.new-project-item-link').click + find('.js-new-project-item-link').click expect(current_path).to eq(new_group_milestone_path(group)) end diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 7345bfa19e2..b00bdeac3b9 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -5,10 +5,11 @@ require 'spec_helper' RSpec.describe 'Dashboard Todos' do include DesignManagementTestHelpers - let_it_be(:user) { create(:user, username: 'john') } - let_it_be(:author) { create(:user) } + let_it_be(:user) { create(:user, username: 'john') } + let_it_be(:user2) { create(:user, username: 'diane') } + let_it_be(:author) { create(:user) } let_it_be(:project) { create(:project, :public) } - let_it_be(:issue) { create(:issue, project: project, due_date: Date.today, title: "Fix bug") } + let_it_be(:issue) { create(:issue, project: project, due_date: Date.today, title: "Fix bug") } before_all do project.add_developer(user) @@ -23,6 +24,19 @@ RSpec.describe 'Dashboard Todos' do it 'shows "All done" message' do expect(page).to have_content 'Your To-Do List shows what to work on next' end + + context 'when user was assigned to an issue and marked it as done' do + before do + sign_in(user) + end + + it 'shows "Are you looking for things to do?" message' do + create(:todo, :assigned, :done, user: user, project: project, target: issue, author: user2) + visit dashboard_todos_path + + expect(page).to have_content 'Are you looking for things to do? Take a look at open issues, contribute to a merge request, or mention someone in a comment to automatically assign them a new to-do item.' + end + end end context 'when the todo references a merge request' do diff --git a/spec/features/dashboard/user_filters_projects_spec.rb b/spec/features/dashboard/user_filters_projects_spec.rb index 9fa77d5917d..f6821ae66e8 100644 --- a/spec/features/dashboard/user_filters_projects_spec.rb +++ b/spec/features/dashboard/user_filters_projects_spec.rb @@ -168,7 +168,7 @@ RSpec.describe 'Dashboard > User filters projects' do sorting_dropdown.click - ['Last updated', 'Created date', 'Name', 'Stars'].each do |label| + ['Updated date', 'Created date', 'Name', 'Stars'].each do |label| expect(sorting_dropdown).to have_content(label) end end @@ -192,9 +192,9 @@ RSpec.describe 'Dashboard > User filters projects' do end end - context 'Sorting by Last updated' do + context 'Sorting by Updated date' do it 'sorts the project list' do - select_dropdown_option '#filtered-search-sorting-dropdown', 'Last updated' + select_dropdown_option '#filtered-search-sorting-dropdown', 'Updated date' expect_to_see_projects(desc_sorted_project_names) diff --git a/spec/features/graphiql_spec.rb b/spec/features/graphiql_spec.rb index 91f53b4bb7c..7729cdaa362 100644 --- a/spec/features/graphiql_spec.rb +++ b/spec/features/graphiql_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'GraphiQL' do end it 'has the correct graphQLEndpoint' do - expect(page.body).to include('var graphQLEndpoint = "/api/graphql";') + expect(page.body).to include('<div id="graphiql-container" data-graphql-endpoint-path="/api/graphql"') end end @@ -26,7 +26,7 @@ RSpec.describe 'GraphiQL' do end it 'has the correct graphQLEndpoint' do - expect(page.body).to include('var graphQLEndpoint = "/gitlab/root/api/graphql";') + expect(page.body).to include('<div id="graphiql-container" data-graphql-endpoint-path="/gitlab/root/api/graphql"') end end end diff --git a/spec/features/groups/dependency_proxy_for_containers_spec.rb b/spec/features/groups/dependency_proxy_for_containers_spec.rb index a4cd6d0f503..ae721e7b91f 100644 --- a/spec/features/groups/dependency_proxy_for_containers_spec.rb +++ b/spec/features/groups/dependency_proxy_for_containers_spec.rb @@ -81,28 +81,11 @@ RSpec.describe 'Group Dependency Proxy for containers', :js do let!(:dependency_proxy_blob) { create(:dependency_proxy_blob, group: group) } it_behaves_like 'responds with the file' - - context 'dependency_proxy_workhorse feature flag disabled' do - before do - stub_feature_flags({ dependency_proxy_workhorse: false }) - end - - it_behaves_like 'responds with the file' - end end end context 'when the blob must be downloaded' do it_behaves_like 'responds with the file' it_behaves_like 'caches the file' - - context 'dependency_proxy_workhorse feature flag disabled' do - before do - stub_feature_flags({ dependency_proxy_workhorse: false }) - end - - it_behaves_like 'responds with the file' - it_behaves_like 'caches the file' - end end end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 1bac1bcdf5a..3fc1484826c 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -156,10 +156,10 @@ RSpec.describe 'Group issues page' do expect(page).to have_selector('.manual-ordering') end - it 'each issue item has a user-can-drag css applied' do + it 'each issue item has a gl-cursor-grab css applied' do visit issues_group_path(group, sort: 'relative_position') - expect(page).to have_selector('.issue.user-can-drag', count: 3) + expect(page).to have_selector('.issue.gl-cursor-grab', count: 3) end it 'issues should be draggable and persist order' do diff --git a/spec/features/groups/labels/edit_spec.rb b/spec/features/groups/labels/edit_spec.rb index 2be7f61eeb9..8e6560af352 100644 --- a/spec/features/groups/labels/edit_spec.rb +++ b/spec/features/groups/labels/edit_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'Edit group label' do + include Spec::Support::Helpers::ModalHelpers + let(:user) { create(:user) } let(:group) { create(:group) } let(:label) { create(:group_label, group: group) } @@ -20,4 +22,16 @@ RSpec.describe 'Edit group label' do expect(current_path).to eq(root_path) expect(label.reload.title).to eq('new label name') end + + it 'allows user to delete label', :js do + click_button 'Delete' + + within_modal do + expect(page).to have_content("#{label.title} will be permanently deleted from #{group.name}. This cannot be undone.") + + click_link 'Delete label' + end + + expect(page).to have_content("#{label.title} deleted permanently") + end end diff --git a/spec/features/groups/labels/sort_labels_spec.rb b/spec/features/groups/labels/sort_labels_spec.rb index b5657db23cb..df75ff7c3cb 100644 --- a/spec/features/groups/labels/sort_labels_spec.rb +++ b/spec/features/groups/labels/sort_labels_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'Sort labels', :js do expect(sort_options[1]).to eq('Name, descending') expect(sort_options[2]).to eq('Last created') expect(sort_options[3]).to eq('Oldest created') - expect(sort_options[4]).to eq('Last updated') + expect(sort_options[4]).to eq('Updated date') expect(sort_options[5]).to eq('Oldest updated') click_link 'Name, descending' diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 077f680629f..7541e54f014 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -67,7 +67,7 @@ RSpec.describe 'Group merge requests page' do end it 'shows projects only with merge requests feature enabled', :js do - find('.new-project-item-link').click + find('.js-new-project-item-link').click page.within('.select2-results') do expect(page).to have_content(project.name_with_namespace) diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index da8032dc4dd..c5d2f5e6733 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -9,7 +9,8 @@ RSpec.describe 'Group navbar' do include_context 'group navbar structure' let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + + let(:group) { create(:group) } before do insert_package_nav(_('Kubernetes')) @@ -40,7 +41,9 @@ RSpec.describe 'Group navbar' do it_behaves_like 'verified navigation bar' end - context 'when customer_relations feature flag is enabled' do + context 'when customer_relations feature and flag is enabled' do + let(:group) { create(:group, :crm_enabled) } + before do stub_feature_flags(customer_relations: true) diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb index 3c2ade6b274..26338b03349 100644 --- a/spec/features/groups/packages_spec.rb +++ b/spec/features/groups/packages_spec.rb @@ -42,6 +42,9 @@ RSpec.describe 'Group Packages' do let_it_be(:maven_package) { create(:maven_package, project: second_project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') } let_it_be(:packages) { [npm_package, maven_package] } + let(:package) { packages.first } + let(:package_details_path) { group_package_path(group, package) } + it_behaves_like 'packages list', check_project_name: true it_behaves_like 'package details link' diff --git a/spec/features/groups/settings/access_tokens_spec.rb b/spec/features/groups/settings/access_tokens_spec.rb new file mode 100644 index 00000000000..20787c4c2f5 --- /dev/null +++ b/spec/features/groups/settings/access_tokens_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Group > Settings > Access Tokens', :js do + let_it_be(:user) { create(:user) } + let_it_be(:bot_user) { create(:user, :project_bot) } + let_it_be(:group) { create(:group) } + let_it_be(:resource_settings_access_tokens_path) { group_settings_access_tokens_path(group) } + + before_all do + group.add_owner(user) + end + + before do + stub_feature_flags(bootstrap_confirmation_modals: false) + sign_in(user) + end + + def create_resource_access_token + group.add_maintainer(bot_user) + + create(:personal_access_token, user: bot_user) + end + + context 'when user is not a group owner' do + before do + group.add_maintainer(user) + end + + it_behaves_like 'resource access tokens missing access rights' + end + + describe 'token creation' do + it_behaves_like 'resource access tokens creation', 'group' + + context 'when token creation is not allowed' do + it_behaves_like 'resource access tokens creation disallowed', 'Group access token creation is disabled in this group. You can still use and manage existing tokens.' + end + end + + describe 'active tokens' do + let!(:resource_access_token) { create_resource_access_token } + + it_behaves_like 'active resource access tokens' + end + + describe 'inactive tokens' do + let!(:resource_access_token) { create_resource_access_token } + + it_behaves_like 'inactive resource access tokens', 'This group has no active access tokens.' + end +end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 9c11b84fa8f..19f60ce55d3 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -171,6 +171,28 @@ RSpec.describe 'Group' do expect(page).not_to have_css('.recaptcha') end end + + describe 'showing personalization questions on group creation when it is enabled' do + before do + stub_application_setting(hide_third_party_offers: false) + visit new_group_path(anchor: 'create-group-pane') + end + + it 'renders personalization questions' do + expect(page).to have_content('Now, personalize your GitLab experience') + end + end + + describe 'not showing personalization questions on group creation when it is enabled' do + before do + stub_application_setting(hide_third_party_offers: true) + visit new_group_path(anchor: 'create-group-pane') + end + + it 'does not render personalization questions' do + expect(page).not_to have_content('Now, personalize your GitLab experience') + end + end end describe 'create a nested group', :js do diff --git a/spec/features/help_dropdown_spec.rb b/spec/features/help_dropdown_spec.rb new file mode 100644 index 00000000000..db98f58240d --- /dev/null +++ b/spec/features/help_dropdown_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Help Dropdown", :js do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } + + before do + stub_application_setting(version_check_enabled: true) + end + + context 'when logged in as non-admin' do + before do + sign_in(user) + visit root_path + end + + it 'does not render version data' do + page.within '.header-help' do + find('.header-help-dropdown-toggle').click + + expect(page).not_to have_text('Your GitLab Version') + expect(page).not_to have_text("#{Gitlab.version_info.major}.#{Gitlab.version_info.minor}") + expect(page).not_to have_selector('.version-check-badge') + expect(page).not_to have_text('Up to date') + end + end + end + + context 'when logged in as admin' do + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end + + describe 'does render version data' do + where(:response, :ui_text) do + [ + [{ "severity" => "success" }, 'Up to date'], + [{ "severity" => "warning" }, 'Update available'], + [{ "severity" => "danger" }, 'Update ASAP'] + ] + end + + with_them do + before do + allow_next_instance_of(VersionCheck) do |instance| + allow(instance).to receive(:response).and_return(response) + end + visit root_path + end + + it 'renders correct version badge variant' do + page.within '.header-help' do + find('.header-help-dropdown-toggle').click + + expect(page).to have_text('Your GitLab Version') + expect(page).to have_text("#{Gitlab.version_info.major}.#{Gitlab.version_info.minor}") + expect(page).to have_selector('.version-check-badge') + expect(page).to have_text(ui_text) + end + end + end + end + end +end diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb index a1e2990202c..546257b9f10 100644 --- a/spec/features/help_pages_spec.rb +++ b/spec/features/help_pages_spec.rb @@ -28,21 +28,20 @@ RSpec.describe 'Help Pages' do end end - context 'in a production environment with version check enabled' do + describe 'with version check enabled' do + let_it_be(:user) { create(:user) } + before do stub_application_setting(version_check_enabled: true) + allow(User).to receive(:single_user).and_return(double(user, requires_usage_stats_consent?: false)) + allow(user).to receive(:can_read_all_resources?).and_return(true) - stub_rails_env('production') - allow(VersionCheck).to receive(:image_url).and_return('/version-check-url') - - sign_in(create(:user)) + sign_in(user) visit help_path end - it 'has a version check image' do - # Check `data-src` due to lazy image loading - expect(find('.js-version-status-badge', visible: false)['data-src']) - .to end_with('/version-check-url') + it 'renders the version check badge' do + expect(page).to have_selector('.js-gitlab-version-check') end end diff --git a/spec/features/issuables/sorting_list_spec.rb b/spec/features/issuables/sorting_list_spec.rb index f646cdbd71b..bc40fb713ac 100644 --- a/spec/features/issuables/sorting_list_spec.rb +++ b/spec/features/issuables/sorting_list_spec.rb @@ -54,10 +54,10 @@ RSpec.describe 'Sort Issuable List' do context 'in the "merge requests / merged" tab', :js do let(:issuable_type) { :merged_merge_request } - it 'is "last updated"' do + it 'is "updated date"' do visit_merge_requests_with_state(project, 'merged') - expect(page).to have_button 'Last updated' + expect(page).to have_button 'Updated date' expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -66,10 +66,10 @@ RSpec.describe 'Sort Issuable List' do context 'in the "merge requests / closed" tab', :js do let(:issuable_type) { :closed_merge_request } - it 'is "last updated"' do + it 'is "updated date"' do visit_merge_requests_with_state(project, 'closed') - expect(page).to have_button 'Last updated' + expect(page).to have_button 'Updated date' expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) end @@ -95,7 +95,7 @@ RSpec.describe 'Sort Issuable List' do visit_merge_requests_with_state(project, 'open') click_button('Created date') - click_link('Last updated') + click_link('Updated date') expect(first_merge_request).to include(last_updated_issuable.title) expect(last_merge_request).to include(first_updated_issuable.title) @@ -152,10 +152,10 @@ RSpec.describe 'Sort Issuable List' do context 'in the "issues / closed" tab', :js do let(:issuable_type) { :closed_issue } - it 'is "last updated"' do + it 'is "updated date"' do visit_issues_with_state(project, 'closed') - expect(page).to have_button 'Last updated' + expect(page).to have_button 'Updated date' expect(first_issue).to include(last_updated_issuable.title) expect(last_issue).to include(first_updated_issuable.title) end @@ -195,7 +195,7 @@ RSpec.describe 'Sort Issuable List' do visit_issues_with_state(project, 'opened') click_button('Created date') - click_on('Last updated') + click_on('Updated date') expect(page).to have_css('.issue:first-child', text: last_updated_issuable.title) expect(page).to have_css('.issue:last-child', text: first_updated_issuable.title) diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 9da6694c681..868946814c3 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -8,10 +8,9 @@ RSpec.describe 'Issue Sidebar' do let_it_be(:group) { create(:group, :nested) } let_it_be(:project) { create(:project, :public, namespace: group) } let_it_be(:user) { create(:user) } - let_it_be(:label) { create(:label, project: project, title: 'bug') } - let_it_be(:issue) { create(:labeled_issue, project: project, labels: [label]) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:label) { create(:label, project: project, name: 'Label') } let_it_be(:mock_date) { Date.today.at_beginning_of_month + 2.days } - let_it_be(:xss_label) { create(:label, project: project, title: '<script>alert("xss");</script>') } before do stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") @@ -223,14 +222,6 @@ RSpec.describe 'Issue Sidebar' do restore_window_size open_issue_sidebar end - - it 'escapes XSS when viewing issue labels' do - page.within('.block.labels') do - click_on 'Edit' - - expect(page).to have_content '<script>alert("xss");</script>' - end - end end context 'editing issue milestone', :js do @@ -242,62 +233,7 @@ RSpec.describe 'Issue Sidebar' do end context 'editing issue labels', :js do - before do - issue.update!(labels: [label]) - page.within('.block.labels') do - click_on 'Edit' - end - end - - it 'shows the current set of labels' do - page.within('.issuable-show-labels') do - expect(page).to have_content label.title - end - end - - it 'shows option to create a project label' do - page.within('.block.labels') do - expect(page).to have_content 'Create project' - end - end - - context 'creating a project label', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/27992' do - before do - page.within('.block.labels') do - click_link 'Create project' - end - end - - it 'shows dropdown switches to "create label" section' do - page.within('.block.labels') do - expect(page).to have_content 'Create project label' - end - end - - it 'adds new label' do - page.within('.block.labels') do - fill_in 'new_label_name', with: 'wontfix' - page.find('.suggest-colors a', match: :first).click - page.find('button', text: 'Create').click - - page.within('.dropdown-page-one') do - expect(page).to have_content 'wontfix' - end - end - end - - it 'shows error message if label title is taken' do - page.within('.block.labels') do - fill_in 'new_label_name', with: label.title - page.find('.suggest-colors a', match: :first).click - page.find('button', text: 'Create').click - - page.within('.dropdown-page-two') do - expect(page).to have_content 'Title has already been taken' - end - end - end - end + it_behaves_like 'labels sidebar widget' end context 'interacting with collapsed sidebar', :js do diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb index 0a879fdd4d4..cc0d35afd60 100644 --- a/spec/features/issues/service_desk_spec.rb +++ b/spec/features/issues/service_desk_spec.rb @@ -9,8 +9,6 @@ RSpec.describe 'Service Desk Issue Tracker', :js do let_it_be(:support_bot) { User.support_bot } before do - stub_feature_flags(vue_issuables_list: true) - # The following two conditions equate to Gitlab::ServiceDesk.supported == true allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true) allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true) diff --git a/spec/features/issues/user_bulk_edits_issues_spec.rb b/spec/features/issues/user_bulk_edits_issues_spec.rb index 44c23813e3c..625303f89e4 100644 --- a/spec/features/issues/user_bulk_edits_issues_spec.rb +++ b/spec/features/issues/user_bulk_edits_issues_spec.rb @@ -104,6 +104,26 @@ RSpec.describe 'Multiple issue updating from issues#index', :js do end end + describe 'select all issues' do + let!(:issue_2) { create(:issue, project: project) } + + before do + stub_feature_flags(vue_issues_list: true) + end + + it 'after selecting all issues, unchecking one issue only unselects that one issue' do + visit project_issues_path(project) + + click_button 'Edit issues' + check 'Select all' + uncheck issue.title + + expect(page).to have_unchecked_field 'Select all' + expect(page).to have_unchecked_field issue.title + expect(page).to have_checked_field issue_2.title + end + end + def create_closed create(:issue, project: project, state: :closed) end diff --git a/spec/features/issues/user_comments_on_issue_spec.rb b/spec/features/issues/user_comments_on_issue_spec.rb index 09d3ad15641..5d03aa1fc2b 100644 --- a/spec/features/issues/user_comments_on_issue_spec.rb +++ b/spec/features/issues/user_comments_on_issue_spec.rb @@ -11,6 +11,7 @@ RSpec.describe "User comments on issue", :js do before do stub_feature_flags(tribute_autocomplete: false) + stub_feature_flags(sandboxed_mermaid: false) project.add_guest(user) sign_in(user) @@ -49,7 +50,7 @@ RSpec.describe "User comments on issue", :js do add_note(comment) - expect(page.find('svg.mermaid')).to have_content html_content + expect(page.find('svg.mermaid')).not_to have_content 'javascript' within('svg.mermaid') { expect(page).not_to have_selector('img') } end diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb index 6e8b3e4fb7c..875b0a60634 100644 --- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb +++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb @@ -217,7 +217,7 @@ RSpec.describe 'User creates branch and merge request on issue page', :js do # Javascript debounces AJAX calls. # So we have to wait until AJAX requests are started. - # Details are in app/assets/javascripts/create_merge_request_dropdown.js + # Details are in app/assets/javascripts/issues/create_merge_request_dropdown.js # this.refDebounce = _.debounce(...) sleep 0.5 diff --git a/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb b/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb new file mode 100644 index 00000000000..1fa8f533869 --- /dev/null +++ b/spec/features/issues/user_scrolls_to_deeplinked_note_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User scrolls to deep-linked note' do + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:comment_1) { create(:note_on_issue, noteable: issue, project: project, note: 'written first') } + let_it_be(:comments) { create_list(:note_on_issue, 20, noteable: issue, project: project, note: 'spacer note') } + + context 'on issue page', :js do + it 'on comment' do + visit project_issue_path(project, issue, anchor: "note_#{comment_1.id}") + + wait_for_requests + + expect(first_comment).to have_content(comment_1.note) + + bottom_of_title = find('.issue-sticky-header.gl-fixed').evaluate_script("this.getBoundingClientRect().bottom;") + top = first_comment.evaluate_script("this.getBoundingClientRect().top;") + + expect(top).to be_within(1).of(bottom_of_title) + end + end + + def all_comments + all('.timeline > .note.timeline-entry') + end + + def first_comment + all_comments.first + end +end diff --git a/spec/features/issues/user_sees_breadcrumb_links_spec.rb b/spec/features/issues/user_sees_breadcrumb_links_spec.rb index 9f8cd2a769d..669c7c45411 100644 --- a/spec/features/issues/user_sees_breadcrumb_links_spec.rb +++ b/spec/features/issues/user_sees_breadcrumb_links_spec.rb @@ -8,8 +8,6 @@ RSpec.describe 'New issue breadcrumb' do let(:user) { project.creator } before do - stub_feature_flags(vue_issuables_list: false) - sign_in(user) visit(new_project_issue_path(project)) end diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index d3aaf339421..0e5a20fe24a 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -7,6 +7,10 @@ RSpec.describe 'Copy as GFM', :js do include RepoHelpers include ActionView::Helpers::JavaScriptHelper + before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/350454 + end + describe 'Copying rendered GFM' do before do @feat = MarkdownFeature.new diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb index e080c7ffb3f..6a91d4e03c1 100644 --- a/spec/features/markdown/mermaid_spec.rb +++ b/spec/features/markdown/mermaid_spec.rb @@ -5,6 +5,10 @@ require 'spec_helper' RSpec.describe 'Mermaid rendering', :js do let_it_be(:project) { create(:project, :public) } + before do + stub_feature_flags(sandboxed_mermaid: false) + end + it 'renders Mermaid diagrams correctly' do description = <<~MERMAID ```mermaid diff --git a/spec/features/markdown/sandboxed_mermaid_spec.rb b/spec/features/markdown/sandboxed_mermaid_spec.rb new file mode 100644 index 00000000000..f118fb3db66 --- /dev/null +++ b/spec/features/markdown/sandboxed_mermaid_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Sandboxed Mermaid rendering', :js do + let_it_be(:project) { create(:project, :public) } + + before do + stub_feature_flags(sandboxed_mermaid: true) + end + + it 'includes mermaid frame correctly' do + description = <<~MERMAID + ```mermaid + graph TD; + A-->B; + A-->C; + B-->D; + C-->D; + ``` + MERMAID + + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + wait_for_requests + + expected = %(<iframe src="/-/sandbox/mermaid" sandbox="allow-scripts" frameborder="0" scrolling="no") + expect(page.html).to include(expected) + end +end diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 0117cf01e53..8761ee89463 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -96,7 +96,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do context 'view merge request with external CI service' do before do - create(:service, project: project, + create(:integration, project: project, active: true, type: 'DroneCiService', category: 'ci') diff --git a/spec/features/password_reset_spec.rb b/spec/features/password_reset_spec.rb index 31b2b2d15aa..322ccc6a0c0 100644 --- a/spec/features/password_reset_spec.rb +++ b/spec/features/password_reset_spec.rb @@ -44,8 +44,8 @@ RSpec.describe 'Password reset' do visit(edit_user_password_path(reset_password_token: token)) - fill_in 'New password', with: 'hello1234' - fill_in 'Confirm new password', with: 'hello1234' + fill_in 'New password', with: "new" + Gitlab::Password.test_default + fill_in 'Confirm new password', with: "new" + Gitlab::Password.test_default click_button 'Change your password' diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index 24ba55994ae..34eb07d78f1 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -29,7 +29,7 @@ RSpec.describe 'Profile account page', :js do it 'deletes user', :js, :sidekiq_might_not_need_inline do click_button 'Delete account' - fill_in 'password', with: '12345678' + fill_in 'password', with: Gitlab::Password.test_default page.within '.modal' do click_button 'Delete account' @@ -62,66 +62,33 @@ RSpec.describe 'Profile account page', :js do end end - describe 'when I reset feed token' do - it 'resets feed token with `hide_access_tokens` feature flag enabled' do - visit profile_personal_access_tokens_path + it 'allows resetting of feed token' do + visit profile_personal_access_tokens_path - within('[data-testid="feed-token-container"]') do - previous_token = find_field('Feed token').value + within('[data-testid="feed-token-container"]') do + previous_token = find_field('Feed token').value - accept_confirm { click_link('reset this token') } + accept_confirm { click_link('reset this token') } - click_button('Click to reveal') + click_button('Click to reveal') - expect(find_field('Feed token').value).not_to eq(previous_token) - end - end - - it 'resets feed token with `hide_access_tokens` feature flag disabled' do - stub_feature_flags(hide_access_tokens: false) - visit profile_personal_access_tokens_path - - within('.feed-token-reset') do - previous_token = find("#feed_token").value - - accept_confirm { find('[data-testid="reset_feed_token_link"]').click } - - expect(find('#feed_token').value).not_to eq(previous_token) - end + expect(find_field('Feed token').value).not_to eq(previous_token) end end - describe 'when I reset incoming email token' do - before do - allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) - stub_feature_flags(bootstrap_confirmation_modals: false) - end - - it 'resets incoming email token with `hide_access_tokens` feature flag enabled' do - visit profile_personal_access_tokens_path - - within('[data-testid="incoming-email-token-container"]') do - previous_token = find_field('Incoming email token').value - - accept_confirm { click_link('reset this token') } + it 'allows resetting of incoming email token' do + allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) - click_button('Click to reveal') + visit profile_personal_access_tokens_path - expect(find_field('Incoming email token').value).not_to eq(previous_token) - end - end + within('[data-testid="incoming-email-token-container"]') do + previous_token = find_field('Incoming email token').value - it 'resets incoming email token with `hide_access_tokens` feature flag disabled' do - stub_feature_flags(hide_access_tokens: false) - visit profile_personal_access_tokens_path + accept_confirm { click_link('reset this token') } - within('.incoming-email-token-reset') do - previous_token = find('#incoming_email_token').value + click_button('Click to reveal') - accept_confirm { find('[data-testid="reset_email_token_link"]').click } - - expect(find('#incoming_email_token').value).not_to eq(previous_token) - end + expect(find_field('Incoming email token').value).not_to eq(previous_token) end end diff --git a/spec/features/profiles/chat_names_spec.rb b/spec/features/profiles/chat_names_spec.rb index 6270fa7347d..b392d8dfa8e 100644 --- a/spec/features/profiles/chat_names_spec.rb +++ b/spec/features/profiles/chat_names_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Profile > Chat' do let(:user) { create(:user) } - let(:integration) { create(:service) } + let(:integration) { create(:integration) } before do sign_in(user) diff --git a/spec/features/profiles/emails_spec.rb b/spec/features/profiles/emails_spec.rb index 8f05de60be9..24917412826 100644 --- a/spec/features/profiles/emails_spec.rb +++ b/spec/features/profiles/emails_spec.rb @@ -44,7 +44,7 @@ RSpec.describe 'Profile > Emails' do end it 'does not add an invalid email' do - fill_in('Email', with: 'test.@example.com') + fill_in('Email', with: 'test@@example.com') click_button('Add email address') email = user.emails.find_by(email: email) diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index c1e2d19ad9a..b9e59a0239b 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -32,10 +32,10 @@ RSpec.describe 'Profile > SSH Keys' do expect(find('.breadcrumbs-sub-title')).to have_link(attrs[:title]) end - it 'shows a confirmable warning if the key does not start with ssh-' do + it 'shows a confirmable warning if the key begins with an algorithm name that is unsupported' do attrs = attributes_for(:key) - fill_in('Key', with: 'invalid-key') + fill_in('Key', with: 'unsupported-ssh-rsa key') fill_in('Title', with: attrs[:title]) click_button('Add key') diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb index 7059697354d..25fe43617fd 100644 --- a/spec/features/profiles/password_spec.rb +++ b/spec/features/profiles/password_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Profile > Password' do describe 'User puts the same passwords in the field and in the confirmation' do it 'shows a success message' do - fill_passwords('mypassword', 'mypassword') + fill_passwords(Gitlab::Password.test_default, Gitlab::Password.test_default) page.within('.flash-notice') do expect(page).to have_content('Password was successfully updated. Please sign in again.') @@ -79,7 +79,7 @@ RSpec.describe 'Profile > Password' do end context 'Change password' do - let(:new_password) { '22233344' } + let(:new_password) { "new" + Gitlab::Password.test_default } before do sign_in(user) @@ -170,8 +170,8 @@ RSpec.describe 'Profile > Password' do expect(current_path).to eq new_profile_password_path fill_in :user_password, with: user.password - fill_in :user_new_password, with: '12345678' - fill_in :user_password_confirmation, with: '12345678' + fill_in :user_new_password, with: Gitlab::Password.test_default + fill_in :user_password_confirmation, with: Gitlab::Password.test_default click_button 'Set new password' expect(current_path).to eq new_user_session_path diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 135a940807e..f1e5658cd7b 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -132,7 +132,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do describe "feed token" do context "when enabled" do - it "displays feed token with `hide_access_tokens` feature flag enabled" do + it "displays feed token" do allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(false) visit profile_personal_access_tokens_path @@ -143,15 +143,6 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do expect(page).to have_content(feed_token_description) end end - - it "displays feed token with `hide_access_tokens` feature flag disabled" do - stub_feature_flags(hide_access_tokens: false) - allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(false) - visit profile_personal_access_tokens_path - - expect(page).to have_field('Feed token', with: user.feed_token) - expect(page).to have_content(feed_token_description) - end end context "when disabled" do diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb index 1a368676a5e..11e2d24c36a 100644 --- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -34,26 +34,23 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do end it 'changes fragment hash if line number clicked' do - ending_fragment = "L5" - visit_blob find('#L3').click - find("##{ending_fragment}").click + find("#L5").click - expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: ending_fragment))) + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5"))) end it 'with initial fragment hash, changes fragment hash if line number clicked' do fragment = "L1" - ending_fragment = "L5" visit_blob(fragment) find('#L3').click - find("##{ending_fragment}").click + find("#L5").click - expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: ending_fragment))) + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5"))) end end @@ -73,26 +70,23 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do end it 'changes fragment hash if line number clicked' do - ending_fragment = "L5" - visit_blob find('#L3').click - find("##{ending_fragment}").click + find("#L5").click - expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: ending_fragment))) + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5"))) end it 'with initial fragment hash, changes fragment hash if line number clicked' do fragment = "L1" - ending_fragment = "L5" visit_blob(fragment) find('#L3').click - find("##{ending_fragment}").click + find("#L5").click - expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: ending_fragment))) + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5"))) end end end diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 9d05c985af1..62994d19fc0 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -29,6 +29,10 @@ RSpec.describe 'File blob', :js do ).execute end + before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/350455 + end + context 'Ruby file' do before do visit_blob('files/ruby/popen.rb') diff --git a/spec/features/projects/branches/user_deletes_branch_spec.rb b/spec/features/projects/branches/user_deletes_branch_spec.rb index 8fc5c3d2e1b..0d08e7ea10d 100644 --- a/spec/features/projects/branches/user_deletes_branch_spec.rb +++ b/spec/features/projects/branches/user_deletes_branch_spec.rb @@ -32,28 +32,4 @@ RSpec.describe "User deletes branch", :js do expect(page).to have_content('Branch was deleted') end - - context 'when the feature flag :delete_branch_confirmation_modals is disabled' do - before do - stub_feature_flags(bootstrap_confirmation_modals: false) - stub_feature_flags(delete_branch_confirmation_modals: false) - end - - it "deletes branch" do - visit(project_branches_path(project)) - - branch_search = find('input[data-testid="branch-search"]') - - branch_search.set('improve/awesome') - branch_search.native.send_keys(:enter) - - page.within(".js-branch-improve\\/awesome") do - accept_alert { click_link(title: 'Delete branch') } - end - - wait_for_requests - - expect(page).to have_css(".js-branch-improve\\/awesome", visible: :hidden) - end - end end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 2725c6a91be..363d08da024 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -117,7 +117,7 @@ RSpec.describe 'Branches' do it 'sorts the branches by name', :js do visit project_branches_filtered_path(project, state: 'all') - click_button "Last updated" # Open sorting dropdown + click_button "Updated date" # Open sorting dropdown within '[data-testid="branches-dropdown"]' do find('p', text: 'Name').click end @@ -128,7 +128,7 @@ RSpec.describe 'Branches' do it 'sorts the branches by oldest updated', :js do visit project_branches_filtered_path(project, state: 'all') - click_button "Last updated" # Open sorting dropdown + click_button "Updated date" # Open sorting dropdown within '[data-testid="branches-dropdown"]' do find('p', text: 'Oldest updated').click end @@ -175,26 +175,6 @@ RSpec.describe 'Branches' do expect(page).not_to have_content('fix') expect(all('.all-branches').last).to have_selector('li', count: 0) end - - context 'when the delete_branch_confirmation_modals feature flag is disabled' do - it 'removes branch after confirmation', :js do - stub_feature_flags(delete_branch_confirmation_modals: false) - stub_feature_flags(bootstrap_confirmation_modals: false) - - visit project_branches_filtered_path(project, state: 'all') - - search_for_branch('fix') - - expect(page).to have_content('fix') - expect(find('.all-branches')).to have_selector('li', count: 1) - accept_confirm do - within('.js-branch-item', match: :first) { click_link(title: 'Delete branch') } - end - - expect(page).not_to have_content('fix') - expect(find('.all-branches')).to have_selector('li', count: 0) - end - end end context 'on project with 0 branch' do diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 6e88cbf52b5..0c9db24f1d8 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -219,7 +219,7 @@ RSpec.describe 'Gcp Cluster', :js do it 'user does not see the offer' do page.within('.as-third-party-offers') do click_button 'Expand' - check 'Do not display offers from third parties' + check 'Do not display content for customer experience improvement and offers from third parties' click_button 'Save changes' end diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index bcbf2f46f79..d88ff5c1aa5 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -342,24 +342,6 @@ RSpec.describe 'Environment' do expect(page).not_to have_button('Stop') end - context 'when the feature flag :delete_branch_confirmation_modals is disabled' do - before do - stub_feature_flags(delete_branch_confirmation_modals: false) - end - - it 'user deletes the branch with running environment' do - visit project_branches_filtered_path(project, state: 'all', search: 'feature') - - remove_branch_with_hooks(project, user, 'feature') do - within('.js-branch-feature') { click_link(title: 'Delete branch') } - end - - visit_environment(environment) - - expect(page).not_to have_button('Stop') - end - end - ## # This is a workaround for problem described in #24543 # diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index a7e773dda2d..23fcc1fe444 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'Edit Project Settings' do # disable by clicking toggle toggle_feature_off("project[project_feature_attributes][#{tool_name}_access_level]") page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests expect(page).not_to have_selector(".shortcuts-#{shortcut_name}") @@ -32,7 +32,7 @@ RSpec.describe 'Edit Project Settings' do # re-enable by clicking toggle again toggle_feature_on("project[project_feature_attributes][#{tool_name}_access_level]") page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests expect(page).to have_selector(".shortcuts-#{shortcut_name}") diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb index 4e9e129042c..508dec70db6 100644 --- a/spec/features/projects/files/user_browses_files_spec.rb +++ b/spec/features/projects/files/user_browses_files_spec.rb @@ -340,6 +340,7 @@ RSpec.describe "User browses files" do let(:newrev) { project.repository.commit('master').sha } before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/350456 create_file_in_repo(project, 'master', 'master', filename, 'Test file') path = File.join('master', filename) @@ -355,6 +356,7 @@ RSpec.describe "User browses files" do context "when browsing a raw file" do before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/350456 path = File.join(RepoHelpers.sample_commit.id, RepoHelpers.sample_blob.path) visit(project_blob_path(project, path)) diff --git a/spec/features/projects/files/user_browses_lfs_files_spec.rb b/spec/features/projects/files/user_browses_lfs_files_spec.rb index 3be5ab64834..17699847704 100644 --- a/spec/features/projects/files/user_browses_lfs_files_spec.rb +++ b/spec/features/projects/files/user_browses_lfs_files_spec.rb @@ -35,7 +35,7 @@ RSpec.describe 'Projects > Files > User browses LFS files' do expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' expect(page).to have_content 'oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897' expect(page).to have_content 'size 1575078' - expect(page).not_to have_content 'Download (1.5 MB)' + expect(page).not_to have_content 'Download (1.50 MiB)' end end @@ -56,7 +56,7 @@ RSpec.describe 'Projects > Files > User browses LFS files' do click_link('lfs') click_link('lfs_object.iso') - expect(page).to have_content('Download (1.5 MB)') + expect(page).to have_content('Download (1.50 MiB)') expect(page).not_to have_content('version https://git-lfs.github.com/spec/v1') expect(page).not_to have_content('oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897') expect(page).not_to have_content('size 1575078') @@ -88,7 +88,7 @@ RSpec.describe 'Projects > Files > User browses LFS files' do it 'does not show single file edit link' do page.within('.content') do expect(page).to have_selector(:link_or_button, 'Web IDE') - expect(page).not_to have_selector(:link_or_button, 'Edit') + expect(page).not_to have_css('button[data-testid="edit"') end end end diff --git a/spec/features/projects/files/user_deletes_files_spec.rb b/spec/features/projects/files/user_deletes_files_spec.rb index b6e300e9e59..c508b2ddba9 100644 --- a/spec/features/projects/files/user_deletes_files_spec.rb +++ b/spec/features/projects/files/user_deletes_files_spec.rb @@ -15,6 +15,7 @@ RSpec.describe 'Projects > Files > User deletes files', :js do let(:user) { create(:user) } before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/349953 sign_in(user) end diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index 453cc14c267..2b4ac3dc1d8 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -150,7 +150,7 @@ RSpec.describe 'Projects > Files > User edits files', :js do expect_fork_prompt - click_link_or_button('Fork project') + click_link_or_button('Fork') expect_fork_status @@ -169,7 +169,7 @@ RSpec.describe 'Projects > Files > User edits files', :js do expect_fork_prompt - click_link_or_button('Fork project') + click_link_or_button('Fork') expect_fork_status @@ -183,7 +183,7 @@ RSpec.describe 'Projects > Files > User edits files', :js do click_link_or_button('Edit') expect_fork_prompt - click_link_or_button('Fork project') + click_link_or_button('Fork') find('.file-editor', match: :first) @@ -214,7 +214,7 @@ RSpec.describe 'Projects > Files > User edits files', :js do click_link('.gitignore') click_link_or_button('Edit') - expect(page).not_to have_link('Fork project') + expect(page).not_to have_link('Fork') find('#editor') set_editor_value('*.rbca') diff --git a/spec/features/projects/files/user_replaces_files_spec.rb b/spec/features/projects/files/user_replaces_files_spec.rb index c9b472260bd..fe9520fffc8 100644 --- a/spec/features/projects/files/user_replaces_files_spec.rb +++ b/spec/features/projects/files/user_replaces_files_spec.rb @@ -17,6 +17,7 @@ RSpec.describe 'Projects > Files > User replaces files', :js do let(:user) { create(:user) } before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/349953 sign_in(user) end diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 3afd1937652..2fbec4e22f4 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -10,12 +10,11 @@ RSpec.describe 'Import/Export - project import integration test', :js do let(:export_path) { "#{Dir.tmpdir}/import_file_spec" } before do - stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) stub_uploads_object_storage(FileUploader) allow_next_instance_of(Gitlab::ImportExport) do |instance| allow(instance).to receive(:storage_path).and_return(export_path) end - gitlab_sign_in(user) + sign_in(user) end after do diff --git a/spec/features/projects/integrations/user_activates_jira_spec.rb b/spec/features/projects/integrations/user_activates_jira_spec.rb index 7a035248440..50010950f0e 100644 --- a/spec/features/projects/integrations/user_activates_jira_spec.rb +++ b/spec/features/projects/integrations/user_activates_jira_spec.rb @@ -20,7 +20,7 @@ RSpec.describe 'User activates Jira', :js do it 'activates the Jira service' do expect(page).to have_content('Jira settings saved and active.') - expect(current_path).to eq(edit_project_service_path(project, :jira)) + expect(current_path).to eq(edit_project_integration_path(project, :jira)) end unless Gitlab.ee? @@ -41,7 +41,7 @@ RSpec.describe 'User activates Jira', :js do fill_in 'service_password', with: 'password' click_test_integration - page.within('.service-settings') do + page.within('[data-testid="integration-settings-form"]') do expect(page).to have_content('This field is required.') end end @@ -55,7 +55,7 @@ RSpec.describe 'User activates Jira', :js do click_test_then_save_integration expect(page).to have_content('Jira settings saved and active.') - expect(current_path).to eq(edit_project_service_path(project, :jira)) + expect(current_path).to eq(edit_project_integration_path(project, :jira)) end end end @@ -72,7 +72,7 @@ RSpec.describe 'User activates Jira', :js do it 'saves but does not activate the Jira service' do expect(page).to have_content('Jira settings saved, but not active.') - expect(current_path).to eq(edit_project_service_path(project, :jira)) + expect(current_path).to eq(edit_project_integration_path(project, :jira)) end it 'does not show the Jira link in the menu' do diff --git a/spec/features/projects/labels/sort_labels_spec.rb b/spec/features/projects/labels/sort_labels_spec.rb index 83559b816d2..26b3d08253c 100644 --- a/spec/features/projects/labels/sort_labels_spec.rb +++ b/spec/features/projects/labels/sort_labels_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'Sort labels', :js do expect(sort_options[1]).to eq('Name, descending') expect(sort_options[2]).to eq('Last created') expect(sort_options[3]).to eq('Oldest created') - expect(sort_options[4]).to eq('Last updated') + expect(sort_options[4]).to eq('Updated date') expect(sort_options[5]).to eq('Oldest updated') click_link 'Name, descending' diff --git a/spec/features/projects/labels/user_edits_labels_spec.rb b/spec/features/projects/labels/user_edits_labels_spec.rb index 8300a1a8542..999c238c7b3 100644 --- a/spec/features/projects/labels/user_edits_labels_spec.rb +++ b/spec/features/projects/labels/user_edits_labels_spec.rb @@ -3,6 +3,8 @@ require "spec_helper" RSpec.describe "User edits labels" do + include Spec::Support::Helpers::ModalHelpers + let_it_be(:project) { create(:project_empty_repo, :public) } let_it_be(:label) { create(:label, project: project) } let_it_be(:user) { create(:user) } @@ -24,4 +26,16 @@ RSpec.describe "User edits labels" do expect(page).to have_content(new_title).and have_no_content(label.title) end end + + it 'allows user to delete label', :js do + click_button 'Delete' + + within_modal do + expect(page).to have_content("#{label.title} will be permanently deleted from #{project.name}. This cannot be undone.") + + click_link 'Delete label' + end + + expect(page).to have_content('Label was removed') + end end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 4dedd5689de..f1786c1be40 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -6,10 +6,6 @@ RSpec.describe 'New project', :js do include Select2Helper include Spec::Support::Helpers::Features::TopNavSpecHelpers - before do - stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) - end - context 'as a user' do let(:user) { create(:user) } @@ -179,7 +175,7 @@ RSpec.describe 'New project', :js do it 'does not show the initialize with Readme checkbox on "Import project" tab' do visit new_project_path click_link 'Import project' - first('.js-import-git-toggle-button').click + click_button 'Repo by URL' page.within '#import-project-pane' do expect(page).not_to have_css('input#project_initialize_with_readme') @@ -196,9 +192,7 @@ RSpec.describe 'New project', :js do end it 'selects the user namespace' do - page.within('#blank-project-pane') do - expect(page).to have_select('project[namespace_id]', visible: false, selected: user.username) - end + expect(page).to have_button user.username end end @@ -212,9 +206,7 @@ RSpec.describe 'New project', :js do end it 'selects the group namespace' do - page.within('#blank-project-pane') do - expect(page).to have_select('project[namespace_id]', visible: false, selected: group.name) - end + expect(page).to have_button group.name end end @@ -229,9 +221,7 @@ RSpec.describe 'New project', :js do end it 'selects the group namespace' do - page.within('#blank-project-pane') do - expect(page).to have_select('project[namespace_id]', visible: false, selected: subgroup.full_path) - end + expect(page).to have_button subgroup.full_path end end @@ -249,22 +239,30 @@ RSpec.describe 'New project', :js do end it 'enables the correct visibility options' do - select2(user.namespace_id, from: '#project_namespace_id') + click_button public_group.full_path + click_button user.username + expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).not_to be_disabled - select2(public_group.id, from: '#project_namespace_id') + click_button user.username + click_button public_group.full_path + expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).not_to be_disabled - select2(internal_group.id, from: '#project_namespace_id') + click_button public_group.full_path + click_button internal_group.full_path + expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).to be_disabled - select2(private_group.id, from: '#project_namespace_id') + click_button internal_group.full_path + click_button private_group.full_path + expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).not_to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::INTERNAL}")).to be_disabled expect(find("#project_visibility_level_#{Gitlab::VisibilityLevel::PUBLIC}")).to be_disabled @@ -355,9 +353,7 @@ RSpec.describe 'New project', :js do end it 'selects the group namespace' do - page.within('#blank-project-pane') do - expect(page).to have_select('project[namespace_id]', visible: false, selected: group.full_path) - end + expect(page).to have_button group.full_path end end end diff --git a/spec/features/projects/packages_spec.rb b/spec/features/projects/packages_spec.rb index 7fcc8200b1c..8180f6b9aff 100644 --- a/spec/features/projects/packages_spec.rb +++ b/spec/features/projects/packages_spec.rb @@ -35,6 +35,9 @@ RSpec.describe 'Packages' do let_it_be(:maven_package) { create(:maven_package, project: project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') } let_it_be(:packages) { [npm_package, maven_package] } + let(:package) { packages.first } + let(:package_details_path) { project_package_path(project, package) } + it_behaves_like 'packages list' it_behaves_like 'package details link' diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 6ddc8e43762..5176a7ec5a1 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -1020,6 +1020,103 @@ RSpec.describe 'Pipeline', :js do end end + describe 'GET /:project/-/pipelines/:id/builds with jobs_tab_vue feature flag turned on' do + include_context 'pipeline builds' + + let_it_be(:project) { create(:project, :repository) } + + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + + before do + visit builds_project_pipeline_path(project, pipeline) + end + + it 'shows a list of jobs' do + expect(page).to have_content('Test') + expect(page).to have_content(build_passed.id) + expect(page).to have_content('Deploy') + expect(page).to have_content(build_failed.id) + expect(page).to have_content(build_running.id) + expect(page).to have_content(build_external.id) + expect(page).to have_content('Retry') + expect(page).to have_content('Cancel running') + expect(page).to have_button('Play') + end + + it 'shows jobs tab pane as active' do + expect(page).to have_css('#js-tab-builds.active') + end + + context 'page tabs' do + it 'shows Pipeline, Jobs and DAG tabs with link' do + expect(page).to have_link('Pipeline') + expect(page).to have_link('Jobs') + expect(page).to have_link('Needs') + end + + it 'shows counter in Jobs tab' do + expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s) + end + + it 'shows Jobs tab as active' do + expect(page).to have_css('li.js-builds-tab-link .active') + end + end + + context 'retrying jobs' do + it { expect(page).not_to have_content('retried') } + + context 'when retrying' do + before do + find('[data-testid="retry"]', match: :first).click + end + + it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do + expect(page).not_to have_content('Retry') + end + end + end + + context 'canceling jobs' do + it { expect(page).not_to have_selector('.ci-canceled') } + + context 'when canceling' do + before do + click_on 'Cancel running' + end + + it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do + expect(page).not_to have_content('Cancel running') + end + end + end + + context 'playing manual job' do + before do + within '[data-testid="jobs-tab-table"]' do + click_button('Play') + + wait_for_requests + end + end + + it { expect(build_manual.reload).to be_pending } + end + + context 'when user unschedules a delayed job' do + before do + within '[data-testid="jobs-tab-table"]' do + click_button('Unschedule') + end + end + + it 'unschedules the delayed job and shows play button as a manual job' do + expect(page).to have_button('Play') + expect(page).not_to have_button('Unschedule') + end + end + end + describe 'GET /:project/-/pipelines/:id/failures' do let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') } let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) } diff --git a/spec/features/projects/services/user_activates_issue_tracker_spec.rb b/spec/features/projects/services/user_activates_issue_tracker_spec.rb index 019d50a497b..27c23e7beb5 100644 --- a/spec/features/projects/services/user_activates_issue_tracker_spec.rb +++ b/spec/features/projects/services/user_activates_issue_tracker_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'User activates issue tracker', :js do it 'activates the service' do expect(page).to have_content("#{tracker} settings saved and active.") - expect(current_path).to eq(edit_project_service_path(project, tracker.parameterize(separator: '_'))) + expect(current_path).to eq(edit_project_integration_path(project, tracker.parameterize(separator: '_'))) end it 'shows the link in the menu' do @@ -58,7 +58,7 @@ RSpec.describe 'User activates issue tracker', :js do end expect(page).to have_content("#{tracker} settings saved and active.") - expect(current_path).to eq(edit_project_service_path(project, tracker.parameterize(separator: '_'))) + expect(current_path).to eq(edit_project_integration_path(project, tracker.parameterize(separator: '_'))) end end end @@ -73,7 +73,7 @@ RSpec.describe 'User activates issue tracker', :js do it 'saves but does not activate the service' do expect(page).to have_content("#{tracker} settings saved, but not active.") - expect(current_path).to eq(edit_project_service_path(project, tracker.parameterize(separator: '_'))) + expect(current_path).to eq(edit_project_integration_path(project, tracker.parameterize(separator: '_'))) end it 'does not show the external tracker link in the menu' do diff --git a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb index b2ca0424b6d..74919a99f04 100644 --- a/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/user_activates_mattermost_slash_command_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Set up Mattermost slash commands', :js do let(:mattermost_enabled) { true } describe 'activation' do - let(:edit_path) { edit_project_service_path(project, :mattermost_slash_commands) } + let(:edit_path) { edit_project_integration_path(project, :mattermost_slash_commands) } include_examples 'user activates the Mattermost Slash Command integration' end diff --git a/spec/features/projects/services/user_activates_slack_notifications_spec.rb b/spec/features/projects/services/user_activates_slack_notifications_spec.rb index d5fe8b083ba..38b6ad84c77 100644 --- a/spec/features/projects/services/user_activates_slack_notifications_spec.rb +++ b/spec/features/projects/services/user_activates_slack_notifications_spec.rb @@ -34,7 +34,7 @@ RSpec.describe 'User activates Slack notifications', :js do pipeline_channel: 6, wiki_page_channel: 7) - visit(edit_project_service_path(project, integration)) + visit(edit_project_integration_path(project, integration)) end it 'filters events by channel' do diff --git a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb index bc84ccaa432..d46d1f739b7 100644 --- a/spec/features/projects/services/user_activates_slack_slash_command_spec.rb +++ b/spec/features/projects/services/user_activates_slack_slash_command_spec.rb @@ -24,7 +24,7 @@ RSpec.describe 'Slack slash commands', :js do click_active_checkbox click_on 'Save' - expect(current_path).to eq(edit_project_service_path(project, :slack_slash_commands)) + expect(current_path).to eq(edit_project_integration_path(project, :slack_slash_commands)) expect(page).to have_content('Slack slash commands settings saved, but not active.') end @@ -32,7 +32,7 @@ RSpec.describe 'Slack slash commands', :js do fill_in 'Token', with: 'token' click_on 'Save' - expect(current_path).to eq(edit_project_service_path(project, :slack_slash_commands)) + expect(current_path).to eq(edit_project_integration_path(project, :slack_slash_commands)) expect(page).to have_content('Slack slash commands settings saved and active.') end diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb index d8de9e0449e..122bf267021 100644 --- a/spec/features/projects/settings/access_tokens_spec.rb +++ b/spec/features/projects/settings/access_tokens_spec.rb @@ -7,6 +7,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do let_it_be(:bot_user) { create(:user, :project_bot) } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } + let_it_be(:resource_settings_access_tokens_path) { project_settings_access_tokens_path(project) } before_all do project.add_maintainer(user) @@ -17,78 +18,25 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do sign_in(user) end - def create_project_access_token + def create_resource_access_token project.add_maintainer(bot_user) create(:personal_access_token, user: bot_user) end - def active_project_access_tokens - find('.table.active-tokens') - end - - def no_project_access_tokens_message - find('.settings-message') - end - - def created_project_access_token - find('#created-personal-access-token').value - end - context 'when user is not a project maintainer' do before do project.add_developer(user) end - it 'does not show project access token page' do - visit project_settings_access_tokens_path(project) - - expect(page).to have_content("Page Not Found") - end + it_behaves_like 'resource access tokens missing access rights' end describe 'token creation' do - it 'allows creation of a project access token' do - name = 'My project access token' - - visit project_settings_access_tokens_path(project) - fill_in 'Token name', with: name - - # Set date to 1st of next month - find_field('Expiration date').click - find('.pika-next').click - click_on '1' - - # Scopes - check 'api' - check 'read_api' - - click_on 'Create project access token' - - expect(active_project_access_tokens).to have_text(name) - expect(active_project_access_tokens).to have_text('in') - expect(active_project_access_tokens).to have_text('api') - expect(active_project_access_tokens).to have_text('read_api') - expect(active_project_access_tokens).to have_text('Maintainer') - expect(created_project_access_token).not_to be_empty - end + it_behaves_like 'resource access tokens creation', 'project' context 'when token creation is not allowed' do - before do - group.namespace_settings.update_column(:resource_access_token_creation_allowed, false) - end - - it 'does not show project access token creation form' do - visit project_settings_access_tokens_path(project) - - expect(page).not_to have_selector('#new_project_access_token') - end - - it 'shows project access token creation disabled text' do - visit project_settings_access_tokens_path(project) - - expect(page).to have_text('Project access token creation is disabled in this group. You can still use and manage existing tokens.') - end + it_behaves_like 'resource access tokens creation disallowed', 'Project access token creation is disabled in this group. You can still use and manage existing tokens.' context 'with a project in a personal namespace' do let(:personal_project) { create(:project) } @@ -97,113 +45,25 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do personal_project.add_maintainer(user) end - it 'shows project access token creation form and text' do + it 'shows access token creation form and text' do visit project_settings_access_tokens_path(personal_project) - expect(page).to have_selector('#new_project_access_token') + expect(page).to have_selector('#new_resource_access_token') expect(page).to have_text('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.') end end - - context 'group settings link' do - context 'when user is not a group owner' do - before do - group.add_developer(user) - end - - it 'does not show group settings link' do - visit project_settings_access_tokens_path(project) - - expect(page).not_to have_link('group settings', href: edit_group_path(group)) - end - end - - context 'with nested groups' do - let(:subgroup) { create(:group, parent: group) } - - context 'when user is not a top level group owner' do - before do - subgroup.add_owner(user) - end - - it 'does not show group settings link' do - visit project_settings_access_tokens_path(project) - - expect(page).not_to have_link('group settings', href: edit_group_path(group)) - end - end - end - - context 'when user is a group owner' do - before do - group.add_owner(user) - end - - it 'shows group settings link' do - visit project_settings_access_tokens_path(project) - - expect(page).to have_link('group settings', href: edit_group_path(group)) - end - end - end end end describe 'active tokens' do - let!(:project_access_token) { create_project_access_token } + let!(:resource_access_token) { create_resource_access_token } - it 'shows active project access tokens' do - visit project_settings_access_tokens_path(project) - - expect(active_project_access_tokens).to have_text(project_access_token.name) - end - - context 'when User#time_display_relative is false' do - before do - user.update!(time_display_relative: false) - end - - it 'shows absolute times for expires_at' do - visit project_settings_access_tokens_path(project) - - expect(active_project_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d')) - end - end + it_behaves_like 'active resource access tokens' end describe 'inactive tokens' do - let!(:project_access_token) { create_project_access_token } - - no_active_tokens_text = 'This project has no active access tokens.' + let!(:resource_access_token) { create_resource_access_token } - it 'allows revocation of an active token' do - visit project_settings_access_tokens_path(project) - accept_confirm { click_on 'Revoke' } - - expect(page).to have_selector('.settings-message') - expect(no_project_access_tokens_message).to have_text(no_active_tokens_text) - end - - it 'removes expired tokens from active section' do - project_access_token.update!(expires_at: 5.days.ago) - visit project_settings_access_tokens_path(project) - - expect(page).to have_selector('.settings-message') - expect(no_project_access_tokens_message).to have_text(no_active_tokens_text) - end - - context 'when resource access token creation is not allowed' do - before do - group.namespace_settings.update_column(:resource_access_token_creation_allowed, false) - end - - it 'allows revocation of an active token' do - visit project_settings_access_tokens_path(project) - accept_confirm { click_on 'Revoke' } - - expect(page).to have_selector('.settings-message') - expect(no_project_access_tokens_message).to have_text(no_active_tokens_text) - end - end + it_behaves_like 'inactive resource access tokens', 'This project has no active access tokens.' end end diff --git a/spec/features/projects/settings/project_settings_spec.rb b/spec/features/projects/settings/project_settings_spec.rb index 71b319d192c..b67caa5a5f9 100644 --- a/spec/features/projects/settings/project_settings_spec.rb +++ b/spec/features/projects/settings/project_settings_spec.rb @@ -47,7 +47,7 @@ RSpec.describe 'Projects settings' do # disable by clicking toggle forking_enabled_button.click page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests @@ -77,7 +77,7 @@ RSpec.describe 'Projects settings' do expect(default_award_emojis_input.value).to eq('false') page.within('.sharing-permissions') do - find('input[value="Save changes"]').click + find('[data-testid="project-features-save-button"]').click end wait_for_requests diff --git a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb index 862bae45fc6..77be351f3d8 100644 --- a/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/user_manages_merge_requests_settings_spec.rb @@ -54,7 +54,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .gl-toggle').click - find('input[value="Save changes"]').send_keys(:return) + find('[data-testid="project-features-save-button"]').send_keys(:return) end expect(page).not_to have_content 'Pipelines must succeed' @@ -74,7 +74,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][builds_access_level]"] .gl-toggle').click - find('input[value="Save changes"]').send_keys(:return) + find('[data-testid="project-features-save-button"]').send_keys(:return) end expect(page).to have_content 'Pipelines must succeed' @@ -95,7 +95,7 @@ RSpec.describe 'Projects > Settings > User manages merge request settings' do within('.sharing-permissions-form') do find('.project-feature-controls[data-for="project[project_feature_attributes][merge_requests_access_level]"] .gl-toggle').click - find('input[value="Save changes"]').send_keys(:return) + find('[data-testid="project-features-save-button"]').send_keys(:return) end expect(page).to have_content 'Pipelines must succeed' diff --git a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb index dc551158895..89f6b4237a4 100644 --- a/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb +++ b/spec/features/projects/show/user_sees_setup_shortcut_buttons_spec.rb @@ -316,7 +316,7 @@ RSpec.describe 'Projects > Show > User sees setup shortcut buttons' do visit project_path(project) page.within('.project-buttons') do - expect(page).to have_link('Add Kubernetes cluster', href: new_project_cluster_path(project)) + expect(page).to have_link('Add Kubernetes cluster', href: project_clusters_path(project)) end end diff --git a/spec/features/projects/user_changes_project_visibility_spec.rb b/spec/features/projects/user_changes_project_visibility_spec.rb index 345d16982fd..68fed9b8a74 100644 --- a/spec/features/projects/user_changes_project_visibility_spec.rb +++ b/spec/features/projects/user_changes_project_visibility_spec.rb @@ -5,14 +5,6 @@ require 'spec_helper' RSpec.describe 'User changes public project visibility', :js do include ProjectForksHelper - before do - fork_project(project, project.owner) - - sign_in(project.owner) - - visit edit_project_path(project) - end - shared_examples 'changing visibility to private' do it 'requires confirmation' do visibility_select = first('.project-feature-controls .select-control') @@ -22,7 +14,7 @@ RSpec.describe 'User changes public project visibility', :js do click_button 'Save changes' end - find('.js-legacy-confirm-danger-input').send_keys(project.path_with_namespace) + fill_in 'confirm_name_input', with: project.path_with_namespace page.within '.modal' do click_button 'Reduce project visibility' @@ -34,15 +26,85 @@ RSpec.describe 'User changes public project visibility', :js do end end - context 'when a project is public' do + shared_examples 'does not require confirmation' do + it 'saves without confirmation' do + visibility_select = first('.project-feature-controls .select-control') + visibility_select.select('Private') + + page.within('#js-shared-permissions') do + click_button 'Save changes' + end + + wait_for_requests + + expect(project.reload).to be_private + end + end + + context 'when the project has forks' do + before do + fork_project(project, project.owner) + + sign_in(project.owner) + + visit edit_project_path(project) + end + + context 'when a project is public' do + let(:project) { create(:project, :empty_repo, :public) } + + it_behaves_like 'changing visibility to private' + end + + context 'when the project is internal' do + let(:project) { create(:project, :empty_repo, :internal) } + + it_behaves_like 'changing visibility to private' + end + + context 'when the visibility level is untouched' do + let(:project) { create(:project, :empty_repo, :public) } + + it 'saves without confirmation' do + expect(page).to have_selector('.js-emails-disabled', visible: true) + find('.js-emails-disabled input[type="checkbox"]').click + + page.within('#js-shared-permissions') do + click_button 'Save changes' + end + + wait_for_requests + + expect(project.reload).to be_public + end + end + end + + context 'when the project is not forked' do let(:project) { create(:project, :empty_repo, :public) } - it_behaves_like 'changing visibility to private' + before do + sign_in(project.owner) + + visit edit_project_path(project) + end + + it_behaves_like 'does not require confirmation' end - context 'when the project is internal' do - let(:project) { create(:project, :empty_repo, :internal) } + context 'with unlink_fork_network_upon_visibility_decrease = false' do + let(:project) { create(:project, :empty_repo, :public) } + + before do + stub_feature_flags(unlink_fork_network_upon_visibility_decrease: false) + + fork_project(project, project.owner) + + sign_in(project.owner) + + visit edit_project_path(project) + end - it_behaves_like 'changing visibility to private' + it_behaves_like 'does not require confirmation' end end diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 17c65e645f4..c4e2e3353a4 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -6,7 +6,6 @@ RSpec.describe 'User creates a project', :js do let(:user) { create(:user) } before do - stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) sign_in(user) create(:personal_key, user: user) end @@ -44,9 +43,7 @@ RSpec.describe 'User creates a project', :js do expect(page).to have_checked_field 'Initialize repository with a README' expect(page).to have_checked_field 'Enable Static Application Security Testing (SAST)' - page.within('#content-body') do - click_button('Create project') - end + click_button('Create project') project = Project.last @@ -96,12 +93,10 @@ RSpec.describe 'User creates a project', :js do fill_in :project_name, with: 'A Subgroup Project' fill_in :project_path, with: 'a-subgroup-project' - page.find('.js-select-namespace').click - page.find("div[role='option']", text: subgroup.full_path).click + click_button user.username + click_button subgroup.full_path - page.within('#content-body') do - click_button('Create project') - end + click_button('Create project') expect(page).to have_content("Project 'A Subgroup Project' was successfully created") @@ -125,8 +120,8 @@ RSpec.describe 'User creates a project', :js do fill_in :project_name, with: 'a-new-project' fill_in :project_path, with: 'a-new-project' - page.find('.js-select-namespace').click - page.find("div[role='option']", text: group.full_path).click + click_button user.username + click_button group.full_path page.within('#content-body') do click_button('Create project') diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb index 6a5ed49f1a6..71e43467a39 100644 --- a/spec/features/projects/user_sorts_projects_spec.rb +++ b/spec/features/projects/user_sorts_projects_spec.rb @@ -41,10 +41,10 @@ RSpec.describe 'User sorts projects and order persists' do sign_in(user) visit(explore_projects_path) find('#sort-projects-dropdown').click - first(:link, 'Last updated').click + first(:link, 'Updated date').click end - it_behaves_like "sort order persists across all views", "Last updated", "Last updated" + it_behaves_like "sort order persists across all views", 'Updated date', 'Updated date' end context 'from dashboard projects' do diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb index d220db01c24..94085b075aa 100644 --- a/spec/features/projects/view_on_env_spec.rb +++ b/spec/features/projects/view_on_env_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'View on environment', :js do let(:user) { project.creator } before do + stub_feature_flags(refactor_blob_viewer: false) # This stub will be removed in https://gitlab.com/gitlab-org/gitlab/-/issues/350457 project.add_maintainer(user) end @@ -48,26 +49,6 @@ RSpec.describe 'View on environment', :js do let(:environment) { create(:environment, project: project, name: 'review/feature', external_url: 'http://feature.review.example.com') } let!(:deployment) { create(:deployment, :success, environment: environment, ref: branch_name, sha: sha) } - context 'when visiting the diff of a merge request for the branch' do - let(:merge_request) { create(:merge_request, :simple, source_project: project, source_branch: branch_name) } - - before do - sign_in(user) - - visit diffs_project_merge_request_path(project, merge_request) - - wait_for_requests - end - - it 'has a "View on env" button' do - within '.diffs' do - text = 'View on feature.review.example.com' - url = 'http://feature.review.example.com/ruby/feature' - expect(page).to have_selector("a[title='#{text}'][href='#{url}']") - end - end - end - context 'when visiting a comparison for the branch' do before do sign_in(user) diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 15ec11c256f..4278efc5a8f 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -29,21 +29,6 @@ RSpec.describe 'Protected Branches', :js do expect(page).to have_button('Only a project maintainer or owner can delete a protected branch', disabled: true) end - - context 'when feature flag :delete_branch_confirmation_modals is disabled' do - before do - stub_feature_flags(delete_branch_confirmation_modals: false) - end - - it 'does not allow developer to remove protected branch' do - visit project_branches_path(project) - - find('input[data-testid="branch-search"]').set('fix') - find('input[data-testid="branch-search"]').native.send_keys(:enter) - - expect(page).to have_selector('button[data-testid="remove-protected-branch"][disabled]') - end - end end end @@ -79,32 +64,6 @@ RSpec.describe 'Protected Branches', :js do expect(page).to have_content('No branches to show') end - - context 'when the feature flag :delete_branch_confirmation_modals is disabled' do - before do - stub_feature_flags(delete_branch_confirmation_modals: false) - end - - it 'removes branch after modal confirmation' do - visit project_branches_path(project) - - find('input[data-testid="branch-search"]').set('fix') - find('input[data-testid="branch-search"]').native.send_keys(:enter) - - expect(page).to have_content('fix') - expect(find('.all-branches')).to have_selector('li', count: 1) - page.find('[data-target="#modal-delete-branch"]').click - - expect(page).to have_css('.js-delete-branch[disabled]') - fill_in 'delete_branch_input', with: 'fix' - click_link 'Delete protected branch' - - find('input[data-testid="branch-search"]').set('fix') - find('input[data-testid="branch-search"]').native.send_keys(:enter) - - expect(page).to have_content('No branches to show') - end - end end end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index 22de77f7cd0..49c468976b9 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -268,10 +268,27 @@ RSpec.describe 'Runners' do it 'group runners are not available' do visit project_runners_path(project) + expect(page).not_to have_content 'Group owners can register group runners in the group\'s CI/CD settings.' + expect(page).to have_content 'Ask your group owner to set up a group runner' + end + end + end + + context 'as project maintainer and group owner' do + before do + group.add_owner(user) + end + + context 'project with a group but no group runner' do + let(:project) { create :project, group: group } + + it 'group runners are available' do + visit project_runners_path(project) + expect(page).to have_content 'This group does not have any group runners yet.' - expect(page).to have_content 'Group maintainers can register group runners in the group\'s CI/CD settings.' - expect(page).not_to have_content 'Ask your group maintainer to set up a group runner' + expect(page).to have_content 'Group owners can register group runners in the group\'s CI/CD settings.' + expect(page).not_to have_content 'Ask your group owner to set up a group runner' end end end @@ -296,8 +313,8 @@ RSpec.describe 'Runners' do expect(page).to have_content 'This group does not have any group runners yet.' - expect(page).not_to have_content 'Group maintainers can register group runners in the group\'s CI/CD settings.' - expect(page).to have_content 'Ask your group maintainer to set up a group runner.' + expect(page).not_to have_content 'Group owners can register group runners in the group\'s CI/CD settings.' + expect(page).to have_content 'Ask your group owner to set up a group runner.' end end diff --git a/spec/features/user_sees_marketing_header_spec.rb b/spec/features/user_sees_marketing_header_spec.rb new file mode 100644 index 00000000000..31f088ce010 --- /dev/null +++ b/spec/features/user_sees_marketing_header_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe 'User sees experimental lmarketing header' do + let_it_be(:project) { create(:project, :public) } + + context 'when not logged in' do + context 'when experiment candidate' do + it 'shows marketing header links', :aggregate_failures do + stub_experiments(logged_out_marketing_header: :candidate) + + visit project_path(project) + + expect(page).to have_text "About GitLab" + expect(page).to have_text "Pricing" + expect(page).to have_text "Talk to an expert" + expect(page).to have_text "Sign up now" + expect(page).to have_text "Login" + end + end + + context 'when experiment candidate (trial focused variant)' do + it 'shows marketing header links', :aggregate_failures do + stub_experiments(logged_out_marketing_header: :trial_focused) + + visit project_path(project) + + expect(page).to have_text "About GitLab" + expect(page).to have_text "Pricing" + expect(page).to have_text "Talk to an expert" + expect(page).to have_text "Get a free trial" + expect(page).to have_text "Sign up" + expect(page).to have_text "Login" + end + end + + context 'when experiment control' do + it 'does not show marketing header links', :aggregate_failures do + stub_experiments(logged_out_marketing_header: :control) + + visit project_path(project) + + expect(page).not_to have_text "About GitLab" + expect(page).not_to have_text "Pricing" + expect(page).not_to have_text "Talk to an expert" + expect(page).not_to have_text "Sign up now" + expect(page).not_to have_text "Login" + expect(page).not_to have_text "Get a free trial" + expect(page).not_to have_text "Sign up" + expect(page).to have_text "Sign in / Register" + end + end + end + + context 'when logged in' do + it 'does not show marketing header links', :aggregate_failures do + sign_in(create(:user)) + + stub_experiments(logged_out_marketing_header: :candidate) + + visit project_path(project) + + expect(page).not_to have_text "About GitLab" + expect(page).not_to have_text "Pricing" + expect(page).not_to have_text "Talk to an expert" + end + end +end diff --git a/spec/features/user_sorts_things_spec.rb b/spec/features/user_sorts_things_spec.rb index 6eaa620b538..8e6f6a96bd2 100644 --- a/spec/features/user_sorts_things_spec.rb +++ b/spec/features/user_sorts_things_spec.rb @@ -21,7 +21,7 @@ RSpec.describe "User sorts things" do end it "issues -> project home page -> issues" do - sort_option = "Last updated" + sort_option = 'Updated date' visit(project_issues_path(project)) @@ -34,7 +34,7 @@ RSpec.describe "User sorts things" do end it "issues -> merge requests" do - sort_option = "Last updated" + sort_option = 'Updated date' visit(project_issues_path(project)) @@ -46,7 +46,7 @@ RSpec.describe "User sorts things" do end it "merge requests -> dashboard merge requests" do - sort_option = "Last updated" + sort_option = 'Updated date' visit(project_merge_requests_path(project)) diff --git a/spec/features/users/anonymous_sessions_spec.rb b/spec/features/users/anonymous_sessions_spec.rb index 6b21412ae3d..f9b23626397 100644 --- a/spec/features/users/anonymous_sessions_spec.rb +++ b/spec/features/users/anonymous_sessions_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state do visit new_user_session_path # The session key only gets created after a post fill_in 'user_login', with: 'non-existant@gitlab.org' - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' expect(page).to have_content('Invalid login or password') diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 7ef11194ff9..2780549eea1 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -49,15 +49,15 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do expect(current_path).to eq edit_user_password_path expect(page).to have_content('Please create a password for your new account.') - fill_in 'user_password', with: 'password' - fill_in 'user_password_confirmation', with: 'password' + fill_in 'user_password', with: Gitlab::Password.test_default + fill_in 'user_password_confirmation', with: Gitlab::Password.test_default click_button 'Change your password' expect(current_path).to eq new_user_session_path expect(page).to have_content(I18n.t('devise.passwords.updated_not_active')) fill_in 'user_login', with: user.username - fill_in 'user_password', with: 'password' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' expect_single_session_with_authenticated_ttl @@ -210,7 +210,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do end it 'does not allow sign-in if the user password is updated before entering a one-time code' do - user.update!(password: 'new_password') + user.update!(password: "new" + Gitlab::Password.test_default) enter_code(user.current_otp) @@ -447,7 +447,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do visit new_user_session_path fill_in 'user_login', with: user.email - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' expect(current_path).to eq(new_profile_password_path) @@ -456,7 +456,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do end context 'with invalid username and password' do - let(:user) { create(:user, password: 'not-the-default') } + let(:user) { create(:user, password: "not" + Gitlab::Password.test_default) } it 'blocks invalid login' do expect(authentication_metrics) @@ -767,7 +767,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do visit new_user_session_path fill_in 'user_login', with: user.email - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' @@ -788,7 +788,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do visit new_user_session_path fill_in 'user_login', with: user.email - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' @@ -809,7 +809,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do visit new_user_session_path fill_in 'user_login', with: user.email - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' @@ -844,7 +844,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do visit new_user_session_path fill_in 'user_login', with: user.email - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' fill_in 'user_otp_attempt', with: user.reload.current_otp @@ -870,7 +870,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do visit new_user_session_path fill_in 'user_login', with: user.email - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default click_button 'Sign in' expect_to_be_on_terms_page @@ -878,7 +878,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do expect(current_path).to eq(new_profile_password_path) - fill_in 'user_password', with: '12345678' + fill_in 'user_password', with: Gitlab::Password.test_default fill_in 'user_new_password', with: 'new password' fill_in 'user_password_confirmation', with: 'new password' click_button 'Set new password' diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb index 7e3c1abd6d1..dac244e4300 100644 --- a/spec/finders/ci/runners_finder_spec.rb +++ b/spec/finders/ci/runners_finder_spec.rb @@ -206,7 +206,7 @@ RSpec.describe Ci::RunnersFinder do sub_group_4.runners << runner_sub_group_4 end - describe '#execute' do + shared_examples '#execute' do subject { described_class.new(current_user: user, params: params).execute } shared_examples 'membership equal to :descendants' do @@ -349,6 +349,16 @@ RSpec.describe Ci::RunnersFinder do end end + it_behaves_like '#execute' + + context 'when the FF ci_find_runners_by_ci_mirrors is disabled' do + before do + stub_feature_flags(ci_find_runners_by_ci_mirrors: false) + end + + it_behaves_like '#execute' + end + describe '#sort_key' do subject { described_class.new(current_user: user, params: params.merge(group: group)).sort_key } diff --git a/spec/finders/environments/environments_by_deployments_finder_spec.rb b/spec/finders/environments/environments_by_deployments_finder_spec.rb index 1b86aced67d..8349092c79e 100644 --- a/spec/finders/environments/environments_by_deployments_finder_spec.rb +++ b/spec/finders/environments/environments_by_deployments_finder_spec.rb @@ -22,16 +22,6 @@ RSpec.describe Environments::EnvironmentsByDeploymentsFinder do create(:deployment, :success, environment: environment_two, ref: 'v1.1.0', tag: true, sha: project.commit('HEAD~1').id) end - it 'returns environment when with_tags is set' do - expect(described_class.new(project, user, ref: 'master', commit: commit, with_tags: true).execute) - .to contain_exactly(environment, environment_two) - end - - it 'does not return environment when no with_tags is set' do - expect(described_class.new(project, user, ref: 'master', commit: commit).execute) - .to be_empty - end - it 'does not return environment when commit is not part of deployment' do expect(described_class.new(project, user, ref: 'master', commit: project.commit('feature')).execute) .to be_empty @@ -41,7 +31,7 @@ RSpec.describe Environments::EnvironmentsByDeploymentsFinder do # This tests to ensure we don't call one CommitIsAncestor per environment it 'only calls Gitaly twice when multiple environments are present', :request_store do expect do - result = described_class.new(project, user, ref: 'master', commit: commit, with_tags: true, find_latest: true).execute + result = described_class.new(project, user, ref: 'v1.1.0', commit: commit, find_latest: true).execute expect(result).to contain_exactly(environment_two) end.to change { Gitlab::GitalyClient.get_request_count }.by(2) diff --git a/spec/finders/fork_targets_finder_spec.rb b/spec/finders/fork_targets_finder_spec.rb index 12f01227af8..fe5b50ef030 100644 --- a/spec/finders/fork_targets_finder_spec.rb +++ b/spec/finders/fork_targets_finder_spec.rb @@ -16,7 +16,9 @@ RSpec.describe ForkTargetsFinder do end let!(:developer_group) do - create(:group).tap { |g| g.add_developer(user) } + create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g| + g.add_developer(user) + end end let!(:reporter_group) do @@ -33,11 +35,11 @@ RSpec.describe ForkTargetsFinder do describe '#execute' do it 'returns all user manageable namespaces' do - expect(finder.execute).to match_array([user.namespace, maintained_group, owned_group, project.namespace]) + expect(finder.execute).to match_array([user.namespace, maintained_group, owned_group, project.namespace, developer_group]) end it 'returns only groups when only_groups option is passed' do - expect(finder.execute(only_groups: true)).to match_array([maintained_group, owned_group, project.namespace]) + expect(finder.execute(only_groups: true)).to match_array([maintained_group, owned_group, project.namespace, developer_group]) end it 'returns groups relation when only_groups option is passed' do diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb index f6b87f7eeab..59eeb078e9e 100644 --- a/spec/finders/group_descendants_finder_spec.rb +++ b/spec/finders/group_descendants_finder_spec.rb @@ -17,262 +17,250 @@ RSpec.describe GroupDescendantsFinder do described_class.new(current_user: user, parent_group: group, params: params) end - shared_examples 'group descentants finder examples' do - describe '#has_children?' do + describe '#has_children?' do + it 'is true when there are projects' do + create(:project, namespace: group) + + expect(finder.has_children?).to be_truthy + end + + context 'when there are subgroups' do it 'is true when there are projects' do - create(:project, namespace: group) + create(:group, parent: group) expect(finder.has_children?).to be_truthy end + end + end - context 'when there are subgroups' do - it 'is true when there are projects' do - create(:group, parent: group) + describe '#execute' do + it 'includes projects' do + project = create(:project, namespace: group) - expect(finder.has_children?).to be_truthy - end - end + expect(finder.execute).to contain_exactly(project) end - describe '#execute' do - it 'includes projects' do + context 'when archived is `true`' do + let(:params) { { archived: 'true' } } + + it 'includes archived projects' do + archived_project = create(:project, namespace: group, archived: true) project = create(:project, namespace: group) - expect(finder.execute).to contain_exactly(project) + expect(finder.execute).to contain_exactly(archived_project, project) end + end - context 'when archived is `true`' do - let(:params) { { archived: 'true' } } + context 'when archived is `only`' do + let(:params) { { archived: 'only' } } - it 'includes archived projects' do - archived_project = create(:project, namespace: group, archived: true) - project = create(:project, namespace: group) + it 'includes only archived projects' do + archived_project = create(:project, namespace: group, archived: true) + _project = create(:project, namespace: group) - expect(finder.execute).to contain_exactly(archived_project, project) - end + expect(finder.execute).to contain_exactly(archived_project) end + end - context 'when archived is `only`' do - let(:params) { { archived: 'only' } } + it 'does not include archived projects' do + _archived_project = create(:project, :archived, namespace: group) - it 'includes only archived projects' do - archived_project = create(:project, namespace: group, archived: true) - _project = create(:project, namespace: group) + expect(finder.execute).to be_empty + end - expect(finder.execute).to contain_exactly(archived_project) - end - end + context 'with a filter' do + let(:params) { { filter: 'test' } } - it 'does not include archived projects' do - _archived_project = create(:project, :archived, namespace: group) + it 'includes only projects matching the filter' do + _other_project = create(:project, namespace: group) + matching_project = create(:project, namespace: group, name: 'testproject') - expect(finder.execute).to be_empty + expect(finder.execute).to contain_exactly(matching_project) end + end - context 'with a filter' do - let(:params) { { filter: 'test' } } + it 'sorts elements by name as default' do + project1 = create(:project, namespace: group, name: 'z') + project2 = create(:project, namespace: group, name: 'a') - it 'includes only projects matching the filter' do - _other_project = create(:project, namespace: group) - matching_project = create(:project, namespace: group, name: 'testproject') + expect(subject.execute).to match_array([project2, project1]) + end - expect(finder.execute).to contain_exactly(matching_project) - end + context 'sorting by name' do + let!(:project1) { create(:project, namespace: group, name: 'a', path: 'project-a') } + let!(:project2) { create(:project, namespace: group, name: 'z', path: 'project-z') } + let(:params) do + { + sort: 'name_asc' + } end - it 'sorts elements by name as default' do - project1 = create(:project, namespace: group, name: 'z') - project2 = create(:project, namespace: group, name: 'a') - - expect(subject.execute).to match_array([project2, project1]) + it 'sorts elements by name' do + expect(subject.execute).to eq( + [ + project1, + project2 + ] + ) end - context 'sorting by name' do - let!(:project1) { create(:project, namespace: group, name: 'a', path: 'project-a') } - let!(:project2) { create(:project, namespace: group, name: 'z', path: 'project-z') } - let(:params) do - { - sort: 'name_asc' - } - end + context 'with nested groups' do + let!(:subgroup1) { create(:group, parent: group, name: 'a', path: 'sub-a') } + let!(:subgroup2) { create(:group, parent: group, name: 'z', path: 'sub-z') } it 'sorts elements by name' do expect(subject.execute).to eq( [ + subgroup1, + subgroup2, project1, project2 ] ) end - - context 'with nested groups' do - let!(:subgroup1) { create(:group, parent: group, name: 'a', path: 'sub-a') } - let!(:subgroup2) { create(:group, parent: group, name: 'z', path: 'sub-z') } - - it 'sorts elements by name' do - expect(subject.execute).to eq( - [ - subgroup1, - subgroup2, - project1, - project2 - ] - ) - end - end end + end - it 'does not include projects shared with the group' do - project = create(:project, namespace: group) - other_project = create(:project) - other_project.project_group_links.create!(group: group, - group_access: Gitlab::Access::MAINTAINER) + it 'does not include projects shared with the group' do + project = create(:project, namespace: group) + other_project = create(:project) + other_project.project_group_links.create!(group: group, + group_access: Gitlab::Access::MAINTAINER) - expect(finder.execute).to contain_exactly(project) - end + expect(finder.execute).to contain_exactly(project) end + end - context 'with shared groups' do - let_it_be(:other_group) { create(:group) } - let_it_be(:shared_group_link) do - create(:group_group_link, - shared_group: group, - shared_with_group: other_group) - end + context 'with shared groups' do + let_it_be(:other_group) { create(:group) } + let_it_be(:shared_group_link) do + create(:group_group_link, + shared_group: group, + shared_with_group: other_group) + end - context 'without common ancestor' do + context 'without common ancestor' do + it { expect(finder.execute).to be_empty } + end + + context 'with common ancestor' do + let_it_be(:common_ancestor) { create(:group) } + let_it_be(:other_group) { create(:group, parent: common_ancestor) } + let_it_be(:group) { create(:group, parent: common_ancestor) } + + context 'querying under the common ancestor' do it { expect(finder.execute).to be_empty } end - context 'with common ancestor' do - let_it_be(:common_ancestor) { create(:group) } - let_it_be(:other_group) { create(:group, parent: common_ancestor) } - let_it_be(:group) { create(:group, parent: common_ancestor) } - - context 'querying under the common ancestor' do - it { expect(finder.execute).to be_empty } + context 'querying the common ancestor' do + subject(:finder) do + described_class.new(current_user: user, parent_group: common_ancestor, params: params) end - context 'querying the common ancestor' do - subject(:finder) do - described_class.new(current_user: user, parent_group: common_ancestor, params: params) - end - - it 'contains shared subgroups' do - expect(finder.execute).to contain_exactly(group, other_group) - end + it 'contains shared subgroups' do + expect(finder.execute).to contain_exactly(group, other_group) end end end + end - context 'with nested groups' do - let!(:project) { create(:project, namespace: group) } - let!(:subgroup) { create(:group, :private, parent: group) } + context 'with nested groups' do + let!(:project) { create(:project, namespace: group) } + let!(:subgroup) { create(:group, :private, parent: group) } - describe '#execute' do - it 'contains projects and subgroups' do - expect(finder.execute).to contain_exactly(subgroup, project) - end + describe '#execute' do + it 'contains projects and subgroups' do + expect(finder.execute).to contain_exactly(subgroup, project) + end - it 'does not include subgroups the user does not have access to' do - subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + it 'does not include subgroups the user does not have access to' do + subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - public_subgroup = create(:group, :public, parent: group, path: 'public-group') - other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group') - other_user = create(:user) - other_subgroup.add_developer(other_user) + public_subgroup = create(:group, :public, parent: group, path: 'public-group') + other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group') + other_user = create(:user) + other_subgroup.add_developer(other_user) - finder = described_class.new(current_user: other_user, parent_group: group) + finder = described_class.new(current_user: other_user, parent_group: group) - expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup) - end + expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup) + end - it 'only includes public groups when no user is given' do - public_subgroup = create(:group, :public, parent: group) - _private_subgroup = create(:group, :private, parent: group) + it 'only includes public groups when no user is given' do + public_subgroup = create(:group, :public, parent: group) + _private_subgroup = create(:group, :private, parent: group) - finder = described_class.new(current_user: nil, parent_group: group) + finder = described_class.new(current_user: nil, parent_group: group) - expect(finder.execute).to contain_exactly(public_subgroup) - end + expect(finder.execute).to contain_exactly(public_subgroup) + end - context 'when archived is `true`' do - let(:params) { { archived: 'true' } } + context 'when archived is `true`' do + let(:params) { { archived: 'true' } } - it 'includes archived projects in the count of subgroups' do - create(:project, namespace: subgroup, archived: true) + it 'includes archived projects in the count of subgroups' do + create(:project, namespace: subgroup, archived: true) - expect(finder.execute.first.preloaded_project_count).to eq(1) - end + expect(finder.execute.first.preloaded_project_count).to eq(1) end + end - context 'with a filter' do - let(:params) { { filter: 'test' } } + context 'with a filter' do + let(:params) { { filter: 'test' } } - it 'contains only matching projects and subgroups' do - matching_project = create(:project, namespace: group, name: 'Testproject') - matching_subgroup = create(:group, name: 'testgroup', parent: group) + it 'contains only matching projects and subgroups' do + matching_project = create(:project, namespace: group, name: 'Testproject') + matching_subgroup = create(:group, name: 'testgroup', parent: group) - expect(finder.execute).to contain_exactly(matching_subgroup, matching_project) - end + expect(finder.execute).to contain_exactly(matching_subgroup, matching_project) + end - it 'does not include subgroups the user does not have access to' do - _invisible_subgroup = create(:group, :private, parent: group, name: 'test1') - other_subgroup = create(:group, :private, parent: group, name: 'test2') - public_subgroup = create(:group, :public, parent: group, name: 'test3') - other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4') - other_user = create(:user) - other_subgroup.add_developer(other_user) + it 'does not include subgroups the user does not have access to' do + _invisible_subgroup = create(:group, :private, parent: group, name: 'test1') + other_subgroup = create(:group, :private, parent: group, name: 'test2') + public_subgroup = create(:group, :public, parent: group, name: 'test3') + other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4') + other_user = create(:user) + other_subgroup.add_developer(other_user) - finder = described_class.new(current_user: other_user, - parent_group: group, - params: params) + finder = described_class.new(current_user: other_user, + parent_group: group, + params: params) - expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup) - end + expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup) + end - context 'with matching children' do - it 'includes a group that has a subgroup matching the query and its parent' do - matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup) + context 'with matching children' do + it 'includes a group that has a subgroup matching the query and its parent' do + matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup) - expect(finder.execute).to contain_exactly(subgroup, matching_subgroup) - end + expect(finder.execute).to contain_exactly(subgroup, matching_subgroup) + end - it 'includes the parent of a matching project' do - matching_project = create(:project, namespace: subgroup, name: 'Testproject') + it 'includes the parent of a matching project' do + matching_project = create(:project, namespace: subgroup, name: 'Testproject') - expect(finder.execute).to contain_exactly(subgroup, matching_project) - end + expect(finder.execute).to contain_exactly(subgroup, matching_project) + end - context 'with a small page size' do - let(:params) { { filter: 'test', per_page: 1 } } + context 'with a small page size' do + let(:params) { { filter: 'test', per_page: 1 } } - it 'contains all the ancestors of a matching subgroup regardless the page size' do - subgroup = create(:group, :private, parent: group) - matching = create(:group, :private, name: 'testgroup', parent: subgroup) + it 'contains all the ancestors of a matching subgroup regardless the page size' do + subgroup = create(:group, :private, parent: group) + matching = create(:group, :private, name: 'testgroup', parent: subgroup) - expect(finder.execute).to contain_exactly(subgroup, matching) - end + expect(finder.execute).to contain_exactly(subgroup, matching) end + end - it 'does not include the parent itself' do - group.update!(name: 'test') + it 'does not include the parent itself' do + group.update!(name: 'test') - expect(finder.execute).not_to include(group) - end + expect(finder.execute).not_to include(group) end end end end end - - it_behaves_like 'group descentants finder examples' - - context 'when feature flag :linear_group_descendants_finder is disabled' do - before do - stub_feature_flags(linear_group_descendants_finder: false) - end - - it_behaves_like 'group descentants finder examples' - end end diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index 0d797b7923c..a9a8e9d19b8 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -3,83 +3,93 @@ require 'spec_helper' RSpec.describe GroupMembersFinder, '#execute' do - let(:group) { create(:group) } - let(:sub_group) { create(:group, parent: group) } - let(:sub_sub_group) { create(:group, parent: sub_group) } - let(:user1) { create(:user) } - let(:user2) { create(:user) } - let(:user3) { create(:user) } - let(:user4) { create(:user) } - let(:user5) { create(:user, :two_factor_via_otp) } + let_it_be(:group) { create(:group) } + let_it_be(:sub_group) { create(:group, parent: group) } + let_it_be(:sub_sub_group) { create(:group, parent: sub_group) } + let_it_be(:public_shared_group) { create(:group, :public) } + let_it_be(:private_shared_group) { create(:group, :private) } + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be(:user3) { create(:user) } + let_it_be(:user4) { create(:user) } + let_it_be(:user5) { create(:user, :two_factor_via_otp) } + + let!(:link) do + create(:group_group_link, shared_group: group, shared_with_group: public_shared_group) + create(:group_group_link, shared_group: sub_group, shared_with_group: private_shared_group) + end let(:groups) do { - group: group, - sub_group: sub_group, - sub_sub_group: sub_sub_group + group: group, + sub_group: sub_group, + sub_sub_group: sub_sub_group, + public_shared_group: public_shared_group, + private_shared_group: private_shared_group } end context 'relations' do let!(:members) do { - user1_sub_sub_group: create(:group_member, :maintainer, group: sub_sub_group, user: user1), - user1_sub_group: create(:group_member, :developer, group: sub_group, user: user1), - user1_group: create(:group_member, :reporter, group: group, user: user1), - user2_sub_sub_group: create(:group_member, :reporter, group: sub_sub_group, user: user2), - user2_sub_group: create(:group_member, :developer, group: sub_group, user: user2), - user2_group: create(:group_member, :maintainer, group: group, user: user2), - user3_sub_sub_group: create(:group_member, :developer, group: sub_sub_group, user: user3, expires_at: 1.day.from_now), - user3_sub_group: create(:group_member, :developer, group: sub_group, user: user3, expires_at: 2.days.from_now), - user3_group: create(:group_member, :reporter, group: group, user: user3), - user4_sub_sub_group: create(:group_member, :reporter, group: sub_sub_group, user: user4), - user4_sub_group: create(:group_member, :developer, group: sub_group, user: user4, expires_at: 1.day.from_now), - user4_group: create(:group_member, :developer, group: group, user: user4, expires_at: 2.days.from_now) + user1_sub_sub_group: create(:group_member, :maintainer, group: sub_sub_group, user: user1), + user1_sub_group: create(:group_member, :developer, group: sub_group, user: user1), + user1_group: create(:group_member, :reporter, group: group, user: user1), + user1_public_shared_group: create(:group_member, :maintainer, group: public_shared_group, user: user1), + user1_private_shared_group: create(:group_member, :maintainer, group: private_shared_group, user: user1), + user2_sub_sub_group: create(:group_member, :reporter, group: sub_sub_group, user: user2), + user2_sub_group: create(:group_member, :developer, group: sub_group, user: user2), + user2_group: create(:group_member, :maintainer, group: group, user: user2), + user2_public_shared_group: create(:group_member, :developer, group: public_shared_group, user: user2), + user2_private_shared_group: create(:group_member, :developer, group: private_shared_group, user: user2), + user3_sub_sub_group: create(:group_member, :developer, group: sub_sub_group, user: user3, expires_at: 1.day.from_now), + user3_sub_group: create(:group_member, :developer, group: sub_group, user: user3, expires_at: 2.days.from_now), + user3_group: create(:group_member, :reporter, group: group, user: user3), + user3_public_shared_group: create(:group_member, :reporter, group: public_shared_group, user: user3), + user3_private_shared_group: create(:group_member, :reporter, group: private_shared_group, user: user3), + user4_sub_sub_group: create(:group_member, :reporter, group: sub_sub_group, user: user4), + user4_sub_group: create(:group_member, :developer, group: sub_group, user: user4, expires_at: 1.day.from_now), + user4_group: create(:group_member, :developer, group: group, user: user4, expires_at: 2.days.from_now), + user4_public_shared_group: create(:group_member, :developer, group: public_shared_group, user: user4), + user4_private_shared_group: create(:group_member, :developer, group: private_shared_group, user: user4) } end it 'raises an error if a non-supported relation type is used' do expect do described_class.new(group).execute(include_relations: [:direct, :invalid_relation_type]) - end.to raise_error(ArgumentError, "invalid_relation_type is not a valid relation type. Valid relation types are direct, inherited, descendants.") + end.to raise_error(ArgumentError, "invalid_relation_type is not a valid relation type. Valid relation types are direct, inherited, descendants, shared_from_groups.") end using RSpec::Parameterized::TableSyntax where(:subject_relations, :subject_group, :expected_members) do - nil | :group | [:user1_group, :user2_group, :user3_group, :user4_group] - [:direct] | :group | [:user1_group, :user2_group, :user3_group, :user4_group] - [:inherited] | :group | [] - [:descendants] | :group | [:user1_sub_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] - [:direct, :inherited] | :group | [:user1_group, :user2_group, :user3_group, :user4_group] - [:direct, :descendants] | :group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:descendants, :inherited] | :group | [:user1_sub_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] - [:direct, :descendants, :inherited] | :group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] - nil | :sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:direct] | :sub_group | [:user1_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] - [:inherited] | :sub_group | [:user1_group, :user2_group, :user3_group, :user4_group] - [:descendants] | :sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] - [:direct, :inherited] | :sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:direct, :descendants] | :sub_group | [:user1_sub_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] - [:descendants, :inherited] | :sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_sub_group, :user4_group] - [:direct, :descendants, :inherited] | :sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] - nil | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:direct] | :sub_sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] - [:inherited] | :sub_sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:descendants] | :sub_sub_group | [] - [:direct, :inherited] | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:direct, :descendants] | :sub_sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] - [:descendants, :inherited] | :sub_sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group] - [:direct, :descendants, :inherited] | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] + [] | :group | [] + GroupMembersFinder::DEFAULT_RELATIONS | :group | [:user1_group, :user2_group, :user3_group, :user4_group] + [:direct] | :group | [:user1_group, :user2_group, :user3_group, :user4_group] + [:inherited] | :group | [] + [:descendants] | :group | [:user1_sub_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] + [:shared_from_groups] | :group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group] + [:direct, :inherited, :descendants, :shared_from_groups] | :group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_public_shared_group] + [] | :sub_group | [] + GroupMembersFinder::DEFAULT_RELATIONS | :sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group] + [:direct] | :sub_group | [:user1_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] + [:inherited] | :sub_group | [:user1_group, :user2_group, :user3_group, :user4_group] + [:descendants] | :sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] + [:shared_from_groups] | :sub_group | [] + [:direct, :inherited, :descendants, :shared_from_groups] | :sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] + [] | :sub_sub_group | [] + GroupMembersFinder::DEFAULT_RELATIONS | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] + [:direct] | :sub_sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] + [:inherited] | :sub_sub_group | [:user1_sub_group, :user2_group, :user3_sub_group, :user4_group] + [:descendants] | :sub_sub_group | [] + [:shared_from_groups] | :sub_sub_group | [] + [:direct, :inherited, :descendants, :shared_from_groups] | :sub_sub_group | [:user1_sub_sub_group, :user2_group, :user3_sub_group, :user4_group] end with_them do it 'returns correct members' do - result = if subject_relations - described_class.new(groups[subject_group]).execute(include_relations: subject_relations) - else - described_class.new(groups[subject_group]).execute - end + result = described_class.new(groups[subject_group]).execute(include_relations: subject_relations) expect(result.to_a).to match_array(expected_members.map { |name| members[name] }) end diff --git a/spec/finders/groups/user_groups_finder_spec.rb b/spec/finders/groups/user_groups_finder_spec.rb index 4cce3ab72eb..a4a9b8d16d0 100644 --- a/spec/finders/groups/user_groups_finder_spec.rb +++ b/spec/finders/groups/user_groups_finder_spec.rb @@ -59,23 +59,6 @@ RSpec.describe Groups::UserGroupsFinder do ) end - context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do - before do - stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) - end - - it 'ignores project creation scope and returns all groups where the user is a direct member' do - is_expected.to match( - [ - public_maintainer_group, - private_maintainer_group, - public_developer_group, - guest_group - ] - ) - end - end - context 'when search is provided' do let(:arguments) { { permission_scope: :create_projects, search: 'maintainer' } } diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 03639bc0b98..0b6c438fd02 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -278,33 +278,38 @@ RSpec.describe MergeRequestsFinder do end describe 'draft state' do - let!(:wip_merge_request1) { create(:merge_request, :simple, author: user, source_project: project5, target_project: project5, title: 'WIP: thing') } - let!(:wip_merge_request2) { create(:merge_request, :simple, author: user, source_project: project6, target_project: project6, title: 'wip thing') } - let!(:wip_merge_request3) { create(:merge_request, :simple, author: user, source_project: project1, target_project: project1, title: '[wip] thing') } - let!(:wip_merge_request4) { create(:merge_request, :simple, author: user, source_project: project1, target_project: project2, title: 'wip: thing') } - let!(:draft_merge_request1) { create(:merge_request, :simple, author: user, source_branch: 'draft1', source_project: project5, target_project: project5, title: 'Draft: thing') } - let!(:draft_merge_request2) { create(:merge_request, :simple, author: user, source_branch: 'draft2', source_project: project6, target_project: project6, title: '[draft] thing') } - let!(:draft_merge_request3) { create(:merge_request, :simple, author: user, source_branch: 'draft3', source_project: project1, target_project: project1, title: '(draft) thing') } - let!(:draft_merge_request4) { create(:merge_request, :simple, author: user, source_branch: 'draft4', source_project: project1, target_project: project2, title: 'Draft - thing') } - - [:wip, :draft].each do |draft_param_key| - it "filters by #{draft_param_key}" do - params = { draft_param_key => 'yes' } + shared_examples 'draft MRs filtering' do |draft_param_key, draft_param_value, title_prefix, draft_only| + it "filters by #{draft_param_key} => #{draft_param_value}" do + merge_request1.reload.update!(title: "#{title_prefix} #{merge_request1.title}") + + params = { draft_param_key => draft_param_value } merge_requests = described_class.new(user, params).execute - expect(merge_requests).to contain_exactly( - merge_request4, merge_request5, wip_merge_request1, wip_merge_request2, wip_merge_request3, wip_merge_request4, - draft_merge_request1, draft_merge_request2, draft_merge_request3, draft_merge_request4 - ) + if draft_only + expect(merge_requests).to contain_exactly(merge_request1, merge_request4, merge_request5) + else + expect(merge_requests).to contain_exactly(merge_request2, merge_request3) + end end + end - it "filters by not #{draft_param_key}" do - params = { draft_param_key => 'no' } - - merge_requests = described_class.new(user, params).execute + { + wip: ["WIP:", "wip", "[wip]"], + draft: ["Draft:", "Draft -", "[Draft]", "(Draft)"] + }.each do |draft_param_key, title_prefixes| + title_prefixes.each do |title_prefix| + it_behaves_like 'draft MRs filtering', draft_param_key, 1, title_prefix, true + it_behaves_like 'draft MRs filtering', draft_param_key, '1', title_prefix, true + it_behaves_like 'draft MRs filtering', draft_param_key, true, title_prefix, true + it_behaves_like 'draft MRs filtering', draft_param_key, 'true', title_prefix, true + it_behaves_like 'draft MRs filtering', draft_param_key, 'yes', title_prefix, true - expect(merge_requests).to contain_exactly(merge_request1, merge_request2, merge_request3) + it_behaves_like 'draft MRs filtering', draft_param_key, 0, title_prefix, false + it_behaves_like 'draft MRs filtering', draft_param_key, '0', title_prefix, false + it_behaves_like 'draft MRs filtering', draft_param_key, false, title_prefix, false + it_behaves_like 'draft MRs filtering', draft_param_key, 'false', title_prefix, false + it_behaves_like 'draft MRs filtering', draft_param_key, 'no', title_prefix, false end it "returns all items if no valid #{draft_param_key} param exists" do @@ -313,43 +318,41 @@ RSpec.describe MergeRequestsFinder do merge_requests = described_class.new(user, params).execute expect(merge_requests).to contain_exactly( - merge_request1, merge_request2, merge_request3, merge_request4, - merge_request5, wip_merge_request1, wip_merge_request2, wip_merge_request3, wip_merge_request4, - draft_merge_request1, draft_merge_request2, draft_merge_request3, draft_merge_request4 + merge_request1, merge_request2, merge_request3, merge_request4, merge_request5 ) end end + end - context 'filter by deployment' do - let_it_be(:project_with_repo) { create(:project, :repository) } + context 'filter by deployment' do + let_it_be(:project_with_repo) { create(:project, :repository) } - it 'returns the relevant merge requests' do - deployment1 = create( - :deployment, - project: project_with_repo, - sha: project_with_repo.commit.id - ) - deployment2 = create( - :deployment, - project: project_with_repo, - sha: project_with_repo.commit.id - ) - deployment1.link_merge_requests(MergeRequest.where(id: [merge_request1.id, merge_request2.id])) - deployment2.link_merge_requests(MergeRequest.where(id: merge_request3.id)) + it 'returns the relevant merge requests' do + deployment1 = create( + :deployment, + project: project_with_repo, + sha: project_with_repo.commit.id + ) + deployment2 = create( + :deployment, + project: project_with_repo, + sha: project_with_repo.commit.id + ) + deployment1.link_merge_requests(MergeRequest.where(id: [merge_request1.id, merge_request2.id])) + deployment2.link_merge_requests(MergeRequest.where(id: merge_request3.id)) - params = { deployment_id: deployment1.id } - merge_requests = described_class.new(user, params).execute + params = { deployment_id: deployment1.id } + merge_requests = described_class.new(user, params).execute - expect(merge_requests).to contain_exactly(merge_request1, merge_request2) - end + expect(merge_requests).to contain_exactly(merge_request1, merge_request2) + end - context 'when a deployment does not contain any merge requests' do - it 'returns an empty result' do - params = { deployment_id: create(:deployment, project: project_with_repo, sha: project_with_repo.commit.sha).id } - merge_requests = described_class.new(user, params).execute + context 'when a deployment does not contain any merge requests' do + it 'returns an empty result' do + params = { deployment_id: create(:deployment, project: project_with_repo, sha: project_with_repo.commit.sha).id } + merge_requests = described_class.new(user, params).execute - expect(merge_requests).to be_empty - end + expect(merge_requests).to be_empty end end end diff --git a/spec/finders/packages/conan/package_file_finder_spec.rb b/spec/finders/packages/conan/package_file_finder_spec.rb index c2f445c58f7..3da7da456c2 100644 --- a/spec/finders/packages/conan/package_file_finder_spec.rb +++ b/spec/finders/packages/conan/package_file_finder_spec.rb @@ -8,7 +8,7 @@ RSpec.describe ::Packages::Conan::PackageFileFinder do let(:package_file_name) { package_file.file_name } let(:params) { {} } - RSpec.shared_examples 'package file finder examples' do + shared_examples 'package file finder examples' do it { is_expected.to eq(package_file) } context 'with conan_file_type' do @@ -39,11 +39,37 @@ RSpec.describe ::Packages::Conan::PackageFileFinder do end end + shared_examples 'not returning pending_destruction package files' do + let_it_be(:recent_package_file_pending_destruction) do + create(:package_file, :pending_destruction, package: package, file_name: package_file.file_name) + end + + it 'returns the correct package file' do + expect(package.package_files.last).to eq(recent_package_file_pending_destruction) + + expect(subject).to eq(package_file) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns the correct package file' do + expect(package.package_files.last).to eq(recent_package_file_pending_destruction) + + expect(subject).to eq(recent_package_file_pending_destruction) + end + end + end + describe '#execute' do subject { described_class.new(package, package_file_name, params).execute } it_behaves_like 'package file finder examples' + it_behaves_like 'not returning pending_destruction package files' + context 'with unknown file_name' do let(:package_file_name) { 'unknown.jpg' } @@ -56,6 +82,8 @@ RSpec.describe ::Packages::Conan::PackageFileFinder do it_behaves_like 'package file finder examples' + it_behaves_like 'not returning pending_destruction package files' + context 'with unknown file_name' do let(:package_file_name) { 'unknown.jpg' } diff --git a/spec/finders/packages/go/package_finder_spec.rb b/spec/finders/packages/go/package_finder_spec.rb index dbcb8255d47..b928336f958 100644 --- a/spec/finders/packages/go/package_finder_spec.rb +++ b/spec/finders/packages/go/package_finder_spec.rb @@ -59,7 +59,7 @@ RSpec.describe Packages::Go::PackageFinder do let(:version_name) { version.name } before do - package.update_column(:status, 1) + package.update_column(:status, :error) end it { is_expected.to eq(nil) } diff --git a/spec/finders/packages/maven/package_finder_spec.rb b/spec/finders/packages/maven/package_finder_spec.rb index 38fc3b7cce4..8b45dbdad51 100644 --- a/spec/finders/packages/maven/package_finder_spec.rb +++ b/spec/finders/packages/maven/package_finder_spec.rb @@ -39,7 +39,7 @@ RSpec.describe ::Packages::Maven::PackageFinder do let(:param_path) { package.maven_metadatum.path } before do - package.update_column(:status, 1) + package.update_column(:status, :error) end it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) } diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb index 230d267e508..7fabb3eed86 100644 --- a/spec/finders/packages/npm/package_finder_spec.rb +++ b/spec/finders/packages/npm/package_finder_spec.rb @@ -52,7 +52,7 @@ RSpec.describe ::Packages::Npm::PackageFinder do context 'with an uninstallable package' do before do - package.update_column(:status, 1) + package.update_column(:status, :error) end it { is_expected.to be_empty } diff --git a/spec/finders/packages/nuget/package_finder_spec.rb b/spec/finders/packages/nuget/package_finder_spec.rb index 045dba295ac..415bf796a72 100644 --- a/spec/finders/packages/nuget/package_finder_spec.rb +++ b/spec/finders/packages/nuget/package_finder_spec.rb @@ -36,7 +36,7 @@ RSpec.describe Packages::Nuget::PackageFinder do context 'with an uninstallable package' do before do - package1.update_column(:status, 1) + package1.update_column(:status, :error) end it { is_expected.to contain_exactly(package2) } diff --git a/spec/finders/packages/package_file_finder_spec.rb b/spec/finders/packages/package_file_finder_spec.rb index 8014f04d917..8b21c9cd3ec 100644 --- a/spec/finders/packages/package_file_finder_spec.rb +++ b/spec/finders/packages/package_file_finder_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Packages::PackageFileFinder do let(:package_file_name) { package_file.file_name } let(:params) { {} } - RSpec.shared_examples 'package file finder examples' do + shared_examples 'package file finder examples' do it { is_expected.to eq(package_file) } context 'with file_name_like' do @@ -19,11 +19,35 @@ RSpec.describe Packages::PackageFileFinder do end end + shared_examples 'not returning pending_destruction package files' do + let_it_be(:recent_package_file_pending_destruction) do + create(:package_file, :pending_destruction, package: package, file_name: package_file.file_name) + end + + it 'returns the correct package file' do + expect(package.package_files.last).to eq(recent_package_file_pending_destruction) + + expect(subject).to eq(package_file) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + expect(subject).to eq(recent_package_file_pending_destruction) + end + end + end + describe '#execute' do subject { described_class.new(package, package_file_name, params).execute } it_behaves_like 'package file finder examples' + it_behaves_like 'not returning pending_destruction package files' + context 'with unknown file_name' do let(:package_file_name) { 'unknown.jpg' } @@ -36,6 +60,8 @@ RSpec.describe Packages::PackageFileFinder do it_behaves_like 'package file finder examples' + it_behaves_like 'not returning pending_destruction package files' + context 'with unknown file_name' do let(:package_file_name) { 'unknown.jpg' } diff --git a/spec/finders/user_group_notification_settings_finder_spec.rb b/spec/finders/user_group_notification_settings_finder_spec.rb index ea44688bc8d..ac59a42d813 100644 --- a/spec/finders/user_group_notification_settings_finder_spec.rb +++ b/spec/finders/user_group_notification_settings_finder_spec.rb @@ -11,167 +11,155 @@ RSpec.describe UserGroupNotificationSettingsFinder do subject.map(&proc).uniq end - shared_examples 'user group notifications settings tests' do - context 'when the groups have no existing notification settings' do - context 'when the groups have no ancestors' do - let_it_be(:groups) { create_list(:group, 3) } - - it 'will be a default Global notification setting', :aggregate_failures do - expect(subject.count).to eq(3) - expect(attributes(&:notification_email)).to match_array([nil]) - expect(attributes(&:level)).to match_array(['global']) - end + context 'when the groups have no existing notification settings' do + context 'when the groups have no ancestors' do + let_it_be(:groups) { create_list(:group, 3) } + + it 'will be a default Global notification setting', :aggregate_failures do + expect(subject.count).to eq(3) + expect(attributes(&:notification_email)).to match_array([nil]) + expect(attributes(&:level)).to match_array(['global']) end + end - context 'when the groups have ancestors' do - context 'when an ancestor has a level other than Global' do - let_it_be(:ancestor_a) { create(:group) } - let_it_be(:group_a) { create(:group, parent: ancestor_a) } - let_it_be(:ancestor_b) { create(:group) } - let_it_be(:group_b) { create(:group, parent: ancestor_b) } - let_it_be(:email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } - - let_it_be(:groups) { [group_a, group_b] } + context 'when the groups have ancestors' do + context 'when an ancestor has a level other than Global' do + let_it_be(:ancestor_a) { create(:group) } + let_it_be(:group_a) { create(:group, parent: ancestor_a) } + let_it_be(:ancestor_b) { create(:group) } + let_it_be(:group_b) { create(:group, parent: ancestor_b) } + let_it_be(:email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } - before do - create(:notification_setting, user: user, source: ancestor_a, level: 'participating', notification_email: email.email) - create(:notification_setting, user: user, source: ancestor_b, level: 'participating', notification_email: email.email) - end + let_it_be(:groups) { [group_a, group_b] } - it 'has the same level set' do - expect(attributes(&:level)).to match_array(['participating']) - end + before do + create(:notification_setting, user: user, source: ancestor_a, level: 'participating', notification_email: email.email) + create(:notification_setting, user: user, source: ancestor_b, level: 'participating', notification_email: email.email) + end - it 'has the same email set' do - expect(attributes(&:notification_email)).to match_array(['ancestor@example.com']) - end + it 'has the same level set' do + expect(attributes(&:level)).to match_array(['participating']) + end - it 'only returns the two queried groups' do - expect(subject.count).to eq(2) - end + it 'has the same email set' do + expect(attributes(&:notification_email)).to match_array(['ancestor@example.com']) end - context 'when an ancestor has a Global level but has an email set' do - let_it_be(:grand_ancestor) { create(:group) } - let_it_be(:ancestor) { create(:group, parent: grand_ancestor) } - let_it_be(:group) { create(:group, parent: ancestor) } - let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } - let_it_be(:grand_email) { create(:email, :confirmed, email: 'grand@example.com', user: user) } - - let_it_be(:groups) { [group] } - - before do - create(:notification_setting, user: user, source: grand_ancestor, level: 'participating', notification_email: grand_email.email) - create(:notification_setting, user: user, source: ancestor, level: 'global', notification_email: ancestor_email.email) - end - - it 'has the same email and level set', :aggregate_failures do - expect(subject.count).to eq(1) - expect(attributes(&:level)).to match_array(['global']) - expect(attributes(&:notification_email)).to match_array(['ancestor@example.com']) - end + it 'only returns the two queried groups' do + expect(subject.count).to eq(2) end + end - context 'when the group has parent_id set but that does not belong to any group' do - let_it_be(:group) { create(:group) } - let_it_be(:groups) { [group] } + context 'when an ancestor has a Global level but has an email set' do + let_it_be(:grand_ancestor) { create(:group) } + let_it_be(:ancestor) { create(:group, parent: grand_ancestor) } + let_it_be(:group) { create(:group, parent: ancestor) } + let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } + let_it_be(:grand_email) { create(:email, :confirmed, email: 'grand@example.com', user: user) } - before do - # Let's set a parent_id for a group that definitely doesn't exist - group.update_columns(parent_id: 19283746) - end + let_it_be(:groups) { [group] } - it 'returns a default Global notification setting' do - expect(subject.count).to eq(1) - expect(attributes(&:level)).to match_array(['global']) - expect(attributes(&:notification_email)).to match_array([nil]) - end + before do + create(:notification_setting, user: user, source: grand_ancestor, level: 'participating', notification_email: grand_email.email) + create(:notification_setting, user: user, source: ancestor, level: 'global', notification_email: ancestor_email.email) end - context 'when the group has a private parent' do - let_it_be(:ancestor) { create(:group, :private) } - let_it_be(:group) { create(:group, :private, parent: ancestor) } - let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } - let_it_be(:groups) { [group] } - - before do - group.add_reporter(user) - # Adding the user creates a NotificationSetting, so we remove it here - user.notification_settings.where(source: group).delete_all - - create(:notification_setting, user: user, source: ancestor, level: 'participating', notification_email: ancestor_email.email) - end - - it 'still inherits the notification settings' do - expect(subject.count).to eq(1) - expect(attributes(&:level)).to match_array(['participating']) - expect(attributes(&:notification_email)).to match_array([ancestor_email.email]) - end + it 'has the same email and level set', :aggregate_failures do + expect(subject.count).to eq(1) + expect(attributes(&:level)).to match_array(['global']) + expect(attributes(&:notification_email)).to match_array(['ancestor@example.com']) end + end - it 'does not cause an N+1', :aggregate_failures do - parent = create(:group) - child = create(:group, parent: parent) - - control = ActiveRecord::QueryRecorder.new do - described_class.new(user, Group.where(id: child.id)).execute - end - - other_parent = create(:group) - other_children = create_list(:group, 2, parent: other_parent) - - result = nil + context 'when the group has parent_id set but that does not belong to any group' do + let_it_be(:group) { create(:group) } + let_it_be(:groups) { [group] } - expect do - result = described_class.new(user, Group.where(id: other_children.append(child).map(&:id))).execute - end.not_to exceed_query_limit(control) + before do + # Let's set a parent_id for a group that definitely doesn't exist + group.update_columns(parent_id: 19283746) + end - expect(result.count).to eq(3) + it 'returns a default Global notification setting' do + expect(subject.count).to eq(1) + expect(attributes(&:level)).to match_array(['global']) + expect(attributes(&:notification_email)).to match_array([nil]) end end - end - context 'preloading `emails_disabled`' do - let_it_be(:root_group) { create(:group) } - let_it_be(:sub_group) { create(:group, parent: root_group) } - let_it_be(:sub_sub_group) { create(:group, parent: sub_group) } + context 'when the group has a private parent' do + let_it_be(:ancestor) { create(:group, :private) } + let_it_be(:group) { create(:group, :private, parent: ancestor) } + let_it_be(:ancestor_email) { create(:email, :confirmed, email: 'ancestor@example.com', user: user) } + let_it_be(:groups) { [group] } - let_it_be(:another_root_group) { create(:group) } - let_it_be(:sub_group_with_emails_disabled) { create(:group, emails_disabled: true, parent: another_root_group) } - let_it_be(:another_sub_sub_group) { create(:group, parent: sub_group_with_emails_disabled) } + before do + group.add_reporter(user) + # Adding the user creates a NotificationSetting, so we remove it here + user.notification_settings.where(source: group).delete_all - let_it_be(:root_group_with_emails_disabled) { create(:group, emails_disabled: true) } - let_it_be(:group) { create(:group, parent: root_group_with_emails_disabled) } - - let(:groups) { Group.where(id: [sub_sub_group, another_sub_sub_group, group]) } + create(:notification_setting, user: user, source: ancestor, level: 'participating', notification_email: ancestor_email.email) + end - before do - described_class.new(user, groups).execute + it 'still inherits the notification settings' do + expect(subject.count).to eq(1) + expect(attributes(&:level)).to match_array(['participating']) + expect(attributes(&:notification_email)).to match_array([ancestor_email.email]) + end end - it 'preloads the `group.emails_disabled` method' do - recorder = ActiveRecord::QueryRecorder.new do - groups.each(&:emails_disabled?) + it 'does not cause an N+1', :aggregate_failures do + parent = create(:group) + child = create(:group, parent: parent) + + control = ActiveRecord::QueryRecorder.new do + described_class.new(user, Group.where(id: child.id)).execute end - expect(recorder.count).to eq(0) - end + other_parent = create(:group) + other_children = create_list(:group, 2, parent: other_parent) - it 'preloads the `group.emails_disabled` method correctly' do - groups.each do |group| - expect(group.emails_disabled?).to eq(Group.find(group.id).emails_disabled?) # compare the memoized and the freshly loaded value - end + result = nil + + expect do + result = described_class.new(user, Group.where(id: other_children.append(child).map(&:id))).execute + end.not_to exceed_query_limit(control) + + expect(result.count).to eq(3) end end end - it_behaves_like 'user group notifications settings tests' + context 'preloading `emails_disabled`' do + let_it_be(:root_group) { create(:group) } + let_it_be(:sub_group) { create(:group, parent: root_group) } + let_it_be(:sub_sub_group) { create(:group, parent: sub_group) } + + let_it_be(:another_root_group) { create(:group) } + let_it_be(:sub_group_with_emails_disabled) { create(:group, emails_disabled: true, parent: another_root_group) } + let_it_be(:another_sub_sub_group) { create(:group, parent: sub_group_with_emails_disabled) } + + let_it_be(:root_group_with_emails_disabled) { create(:group, emails_disabled: true) } + let_it_be(:group) { create(:group, parent: root_group_with_emails_disabled) } + + let(:groups) { Group.where(id: [sub_sub_group, another_sub_sub_group, group]) } - context 'when feature flag :linear_user_group_notification_settings_finder_ancestors_scopes is disabled' do before do - stub_feature_flags(linear_user_group_notification_settings_finder_ancestors_scopes: false) + described_class.new(user, groups).execute + end + + it 'preloads the `group.emails_disabled` method' do + recorder = ActiveRecord::QueryRecorder.new do + groups.each(&:emails_disabled?) + end + + expect(recorder.count).to eq(0) end - it_behaves_like 'user group notifications settings tests' + it 'preloads the `group.emails_disabled` method correctly' do + groups.each do |group| + expect(group.emails_disabled?).to eq(Group.find(group.id).emails_disabled?) # compare the memoized and the freshly loaded value + end + end end end diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb index 74c563b9bf6..6019d22059d 100644 --- a/spec/finders/user_recent_events_finder_spec.rb +++ b/spec/finders/user_recent_events_finder_spec.rb @@ -59,14 +59,46 @@ RSpec.describe UserRecentEventsFinder do expect(events.size).to eq(6) end + context 'selected events' do + let!(:push_event) { create(:push_event, project: public_project, author: project_owner) } + let!(:push_event_second_user) { create(:push_event, project: public_project_second_user, author: second_user) } + + it 'only includes selected events (PUSH) from all users', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::PUSH) + events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute + + expect(events).to contain_exactly(push_event, push_event_second_user) + end + end + it 'does not include events from users with private profile', :aggregate_failures do allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false) events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to include(private_event, internal_event, public_event) - expect(events.size).to eq(3) + expect(events).to contain_exactly(private_event, internal_event, public_event) + end + + context 'with pagination params' do + using RSpec::Parameterized::TableSyntax + + where(:limit, :offset, :ordered_expected_events) do + nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] } + 2 | nil | lazy { [public_event_second_user, internal_event_second_user] } + nil | 4 | lazy { [internal_event, private_event] } + 2 | 2 | lazy { [private_event_second_user, public_event] } + end + + with_them do + let(:params) { { limit: limit, offset: offset }.compact } + + it 'returns paginated events sorted by id (DESC)' do + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + + expect(events).to eq(ordered_expected_events) + end + end end end diff --git a/spec/fixtures/api/schemas/graphql/packages/package_details.json b/spec/fixtures/api/schemas/graphql/packages/package_details.json index 9ef7f6c9271..50e52a7bb87 100644 --- a/spec/fixtures/api/schemas/graphql/packages/package_details.json +++ b/spec/fixtures/api/schemas/graphql/packages/package_details.json @@ -149,6 +149,30 @@ } } } + }, + "npmUrl": { + "type": "string" + }, + "mavenUrl": { + "type": "string" + }, + "conanUrl": { + "type": "string" + }, + "nugetUrl": { + "type": "string" + }, + "pypiUrl": { + "type": "string" + }, + "pypiSetupUrl": { + "type": "string" + }, + "composerUrl": { + "type": "string" + }, + "composerConfigRepositoryUrl": { + "type": "string" } } } diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_request.json b/spec/fixtures/api/schemas/public_api/v4/merge_request.json index c31e91cfef8..a55c4b8974b 100644 --- a/spec/fixtures/api/schemas/public_api/v4/merge_request.json +++ b/spec/fixtures/api/schemas/public_api/v4/merge_request.json @@ -20,6 +20,18 @@ }, "additionalProperties": false }, + "merge_user": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, "merged_at": { "type": ["string", "null"] }, "closed_by": { "type": ["object", "null"], diff --git a/spec/fixtures/ci_secure_files/upload-keystore.jks b/spec/fixtures/ci_secure_files/upload-keystore.jks Binary files differnew file mode 100644 index 00000000000..715adad4a89 --- /dev/null +++ b/spec/fixtures/ci_secure_files/upload-keystore.jks diff --git a/spec/fixtures/error_tracking/go_two_exception_event.json b/spec/fixtures/error_tracking/go_two_exception_event.json new file mode 100644 index 00000000000..97ed8372a27 --- /dev/null +++ b/spec/fixtures/error_tracking/go_two_exception_event.json @@ -0,0 +1 @@ +{"contexts":{"device":{"arch":"amd64","num_cpu":16},"os":{"name":"darwin"},"runtime":{"go_maxprocs":16,"go_numcgocalls":1,"go_numroutines":2,"name":"go","version":"go1.16.10"}},"event_id":"f92492349cda4ceaba1aab9dac55a412","level":"error","platform":"go","release":"v0.12.0-1-g6b72962","sdk":{"name":"sentry.go","version":"0.12.0","integrations":["ContextifyFrames","Environment","IgnoreErrors","Modules"],"packages":[{"name":"sentry-go","version":"0.12.0"}]},"server_name":"jet.fios-router.home","user":{},"modules":{"github.com/getsentry/sentry-go":"(devel)","golang.org/x/sys":"v0.0.0-20211007075335-d3039528d8ac"},"exception":[{"type":"*errors.errorString","value":"unsupported protocol scheme \"\""},{"type":"*url.Error","value":"Get \"foobar\": unsupported protocol scheme \"\"","stacktrace":{"frames":[{"function":"main","module":"main","abs_path":"/Users/stanhu/github/sentry-go/example/basic/main.go","lineno":54,"pre_context":["\t// Set the timeout to the maximum duration the program can afford to wait.","\tdefer sentry.Flush(2 * time.Second)","","\tresp, err := http.Get(os.Args[1])","\tif err != nil {"],"context_line":"\t\tsentry.CaptureException(err)","post_context":["\t\tlog.Printf(\"reported to Sentry: %s\", err)","\t\treturn","\t}","\tdefer resp.Body.Close()",""],"in_app":true}]}}],"timestamp":"2021-12-25T22:32:06.191665-08:00"} diff --git a/spec/fixtures/security_reports/master/gl-sast-report.json b/spec/fixtures/security_reports/master/gl-sast-report.json index 3323c1fffe3..63504e6fccc 100644 --- a/spec/fixtures/security_reports/master/gl-sast-report.json +++ b/spec/fixtures/security_reports/master/gl-sast-report.json @@ -26,6 +26,16 @@ "value": "PREDICTABLE_RANDOM", "url": "https://find-sec-bugs.github.io/bugs.htm#PREDICTABLE_RANDOM" } + ], + "links": [ + { + "name": "Link1", + "url": "https://www.url1.com" + }, + { + "name": "Link2", + "url": "https://www.url2.com" + } ] }, { diff --git a/spec/frontend/__helpers__/matchers.js b/spec/frontend/__helpers__/matchers.js deleted file mode 100644 index 945abdafe9a..00000000000 --- a/spec/frontend/__helpers__/matchers.js +++ /dev/null @@ -1,68 +0,0 @@ -export default { - toHaveSpriteIcon: (element, iconName) => { - if (!iconName) { - throw new Error('toHaveSpriteIcon is missing iconName argument!'); - } - - if (!(element instanceof HTMLElement)) { - throw new Error(`${element} is not a DOM element!`); - } - - const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); - const matchingIcon = iconReferences.find( - (reference) => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, - ); - - const pass = Boolean(matchingIcon); - - let message; - if (pass) { - message = `${element.outerHTML} contains the sprite icon "${iconName}"!`; - } else { - message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`; - - const existingIcons = iconReferences.map((reference) => { - const iconUrl = reference.getAttribute('href'); - return `"${iconUrl.replace(/^.+#/, '')}"`; - }); - if (existingIcons.length > 0) { - message += ` (only found ${existingIcons.join(',')})`; - } - } - - return { - pass, - message: () => message, - }; - }, - toMatchInterpolatedText(received, match) { - let clearReceived; - let clearMatch; - - try { - clearReceived = received.replace(/\s\s+/gm, ' ').replace(/\s\./gm, '.').trim(); - } catch (e) { - return { actual: received, message: 'The received value is not a string', pass: false }; - } - try { - clearMatch = match.replace(/%{\w+}/gm, '').trim(); - } catch (e) { - return { message: 'The comparator value is not a string', pass: false }; - } - const pass = clearReceived === clearMatch; - const message = pass - ? () => ` - \n\n - Expected: ${this.utils.printExpected(clearReceived)} - To not equal: ${this.utils.printReceived(clearMatch)} - ` - : () => - ` - \n\n - Expected: ${this.utils.printExpected(clearReceived)} - To equal: ${this.utils.printReceived(clearMatch)} - `; - - return { actual: received, message, pass }; - }, -}; diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js new file mode 100644 index 00000000000..76571bafb06 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/index.js @@ -0,0 +1,3 @@ +export * from './to_have_sprite_icon'; +export * from './to_have_tracking_attributes'; +export * from './to_match_interpolated_text'; diff --git a/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js new file mode 100644 index 00000000000..bce9d93bea8 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js @@ -0,0 +1,36 @@ +export const toHaveSpriteIcon = (element, iconName) => { + if (!iconName) { + throw new Error('toHaveSpriteIcon is missing iconName argument!'); + } + + if (!(element instanceof HTMLElement)) { + throw new Error(`${element} is not a DOM element!`); + } + + const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); + const matchingIcon = iconReferences.find( + (reference) => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, + ); + + const pass = Boolean(matchingIcon); + + let message; + if (pass) { + message = `${element.outerHTML} contains the sprite icon "${iconName}"!`; + } else { + message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`; + + const existingIcons = iconReferences.map((reference) => { + const iconUrl = reference.getAttribute('href'); + return `"${iconUrl.replace(/^.+#/, '')}"`; + }); + if (existingIcons.length > 0) { + message += ` (only found ${existingIcons.join(',')})`; + } + } + + return { + pass, + message: () => message, + }; +}; diff --git a/spec/frontend/__helpers__/matchers/to_have_tracking_attributes.js b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes.js new file mode 100644 index 00000000000..fd3f3aa042f --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes.js @@ -0,0 +1,35 @@ +import { diff } from 'jest-diff'; +import { isObject, mapValues, isEqual } from 'lodash'; + +export const toHaveTrackingAttributes = (actual, obj) => { + if (!(actual instanceof Element)) { + return { actual, message: () => 'The received value must be an Element.', pass: false }; + } + + if (!isObject(obj)) { + return { + message: () => `The matching object must be an object. Found ${obj}.`, + pass: false, + }; + } + + const actualAttributes = mapValues(obj, (val, key) => actual.getAttribute(`data-track-${key}`)); + + const matcherPass = isEqual(actualAttributes, obj); + + const failMessage = () => { + // We can match, but still fail because we're in a `expect...not.` context + if (matcherPass) { + return `Expected the element's tracking attributes not to match. Found that they matched ${JSON.stringify( + obj, + )}.`; + } + + const objDiff = diff(actualAttributes, obj); + return `Expected the element's tracking attributes to match the given object. Diff: +${objDiff} +`; + }; + + return { actual, message: failMessage, pass: matcherPass }; +}; diff --git a/spec/frontend/__helpers__/matchers/to_have_tracking_attributes_spec.js b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes_spec.js new file mode 100644 index 00000000000..74073ed4063 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_have_tracking_attributes_spec.js @@ -0,0 +1,65 @@ +import { diff } from 'jest-diff'; + +describe('custom matcher toHaveTrackingAttributes', () => { + const createElementWithAttrs = (attributes) => { + const el = document.createElement('div'); + + Object.entries(attributes).forEach(([key, value]) => { + el.setAttribute(key, value); + }); + + return el; + }; + + it('blows up if actual is not an element', () => { + expect(() => { + expect({}).toHaveTrackingAttributes({}); + }).toThrow('The received value must be an Element.'); + }); + + it('blows up if expected is not an object', () => { + expect(() => { + expect(createElementWithAttrs({})).toHaveTrackingAttributes('foo'); + }).toThrow('The matching object must be an object.'); + }); + + it('prints diff when fails', () => { + const expectedDiff = diff({ label: 'foo' }, { label: 'a' }); + expect(() => { + expect(createElementWithAttrs({ 'data-track-label': 'foo' })).toHaveTrackingAttributes({ + label: 'a', + }); + }).toThrow( + `Expected the element's tracking attributes to match the given object. Diff:\n${expectedDiff}\n`, + ); + }); + + describe('positive assertions', () => { + it.each` + attrs | expected + ${{ 'data-track-label': 'foo' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'foo' }} | ${{}} + ${{ 'data-track-label': 'foo', label: 'bar' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ label: 'foo', extra: '123' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ extra: '123' }} + ${{ label: 'foo', extra: '123', id: '7' }} | ${{}} + `('$expected matches element with attrs $attrs', ({ attrs, expected }) => { + expect(createElementWithAttrs(attrs)).toHaveTrackingAttributes(expected); + }); + }); + + describe('negative assertions', () => { + it.each` + attrs | expected + ${{}} | ${{ label: 'foo' }} + ${{ label: 'foo' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'bar', label: 'foo' }} | ${{ label: 'foo' }} + ${{ 'data-track-label': 'foo' }} | ${{ extra: '123' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ label: 'foo', extra: '456' }} + ${{ 'data-track-label': 'foo', 'data-track-extra': '123' }} | ${{ label: 'foo', extra: '123', action: 'click' }} + ${{ label: 'foo', extra: '123', id: '7' }} | ${{ id: '7' }} + `('$expected does not match element with attrs $attrs', ({ attrs, expected }) => { + expect(createElementWithAttrs(attrs)).not.toHaveTrackingAttributes(expected); + }); + }); +}); diff --git a/spec/frontend/__helpers__/matchers/to_match_interpolated_text.js b/spec/frontend/__helpers__/matchers/to_match_interpolated_text.js new file mode 100644 index 00000000000..4ce814a01b4 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_match_interpolated_text.js @@ -0,0 +1,30 @@ +export const toMatchInterpolatedText = (received, match) => { + let clearReceived; + let clearMatch; + + try { + clearReceived = received.replace(/\s\s+/gm, ' ').replace(/\s\./gm, '.').trim(); + } catch (e) { + return { actual: received, message: 'The received value is not a string', pass: false }; + } + try { + clearMatch = match.replace(/%{\w+}/gm, '').trim(); + } catch (e) { + return { message: 'The comparator value is not a string', pass: false }; + } + const pass = clearReceived === clearMatch; + const message = pass + ? () => ` + \n\n + Expected: ${this.utils.printExpected(clearReceived)} + To not equal: ${this.utils.printReceived(clearMatch)} + ` + : () => + ` + \n\n + Expected: ${this.utils.printExpected(clearReceived)} + To equal: ${this.utils.printReceived(clearMatch)} + `; + + return { actual: received, message, pass }; +}; diff --git a/spec/frontend/__helpers__/matchers/to_match_interpolated_text_spec.js b/spec/frontend/__helpers__/matchers/to_match_interpolated_text_spec.js new file mode 100644 index 00000000000..f6fd00011fe --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_match_interpolated_text_spec.js @@ -0,0 +1,46 @@ +describe('custom matcher toMatchInterpolatedText', () => { + describe('malformed input', () => { + it.each([null, 1, Symbol, Array, Object])( + 'fails graciously if the expected value is %s', + (expected) => { + expect(expected).not.toMatchInterpolatedText('null'); + }, + ); + }); + describe('malformed matcher', () => { + it.each([null, 1, Symbol, Array, Object])( + 'fails graciously if the matcher is %s', + (matcher) => { + expect('null').not.toMatchInterpolatedText(matcher); + }, + ); + }); + + describe('positive assertion', () => { + it.each` + htmlString | templateString + ${'foo'} | ${'foo'} + ${'foo'} | ${'foo%{foo}'} + ${'foo '} | ${'foo'} + ${'foo '} | ${'foo%{foo}'} + ${'foo . '} | ${'foo%{foo}.'} + ${'foo bar . '} | ${'foo%{foo} bar.'} + ${'foo\n\nbar . '} | ${'foo%{foo} bar.'} + ${'foo bar . .'} | ${'foo%{fooStart} bar.%{fooEnd}.'} + `('$htmlString equals $templateString', ({ htmlString, templateString }) => { + expect(htmlString).toMatchInterpolatedText(templateString); + }); + }); + + describe('negative assertion', () => { + it.each` + htmlString | templateString + ${'foo'} | ${'bar'} + ${'foo'} | ${'bar%{foo}'} + ${'foo'} | ${'@{lol}foo%{foo}'} + ${' fo o '} | ${'foo'} + `('$htmlString does not equal $templateString', ({ htmlString, templateString }) => { + expect(htmlString).not.toMatchInterpolatedText(templateString); + }); + }); +}); diff --git a/spec/frontend/__helpers__/matchers_spec.js b/spec/frontend/__helpers__/matchers_spec.js deleted file mode 100644 index dfd6f754c72..00000000000 --- a/spec/frontend/__helpers__/matchers_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -describe('Custom jest matchers', () => { - describe('toMatchInterpolatedText', () => { - describe('malformed input', () => { - it.each([null, 1, Symbol, Array, Object])( - 'fails graciously if the expected value is %s', - (expected) => { - expect(expected).not.toMatchInterpolatedText('null'); - }, - ); - }); - describe('malformed matcher', () => { - it.each([null, 1, Symbol, Array, Object])( - 'fails graciously if the matcher is %s', - (matcher) => { - expect('null').not.toMatchInterpolatedText(matcher); - }, - ); - }); - - describe('positive assertion', () => { - it.each` - htmlString | templateString - ${'foo'} | ${'foo'} - ${'foo'} | ${'foo%{foo}'} - ${'foo '} | ${'foo'} - ${'foo '} | ${'foo%{foo}'} - ${'foo . '} | ${'foo%{foo}.'} - ${'foo bar . '} | ${'foo%{foo} bar.'} - ${'foo\n\nbar . '} | ${'foo%{foo} bar.'} - ${'foo bar . .'} | ${'foo%{fooStart} bar.%{fooEnd}.'} - `('$htmlString equals $templateString', ({ htmlString, templateString }) => { - expect(htmlString).toMatchInterpolatedText(templateString); - }); - }); - - describe('negative assertion', () => { - it.each` - htmlString | templateString - ${'foo'} | ${'bar'} - ${'foo'} | ${'bar%{foo}'} - ${'foo'} | ${'@{lol}foo%{foo}'} - ${' fo o '} | ${'foo'} - `('$htmlString does not equal $templateString', ({ htmlString, templateString }) => { - expect(htmlString).not.toMatchInterpolatedText(templateString); - }); - }); - }); -}); diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js index 03389e16b65..7b5df18ee0f 100644 --- a/spec/frontend/__helpers__/shared_test_setup.js +++ b/spec/frontend/__helpers__/shared_test_setup.js @@ -8,7 +8,7 @@ import setWindowLocation from './set_window_location_helper'; import { setGlobalDateToFakeDate } from './fake_date'; import { loadHTMLFixture, setHTMLFixture } from './fixtures'; import { TEST_HOST } from './test_constants'; -import customMatchers from './matchers'; +import * as customMatchers from './matchers'; import './dom_shims'; import './jquery'; diff --git a/spec/frontend/__helpers__/wait_using_real_timer.js b/spec/frontend/__helpers__/wait_using_real_timer.js deleted file mode 100644 index 110d5f46c08..00000000000 --- a/spec/frontend/__helpers__/wait_using_real_timer.js +++ /dev/null @@ -1,7 +0,0 @@ -/* useful for timing promises when jest fakeTimers are not reliable enough */ -export default (timeout) => - new Promise((resolve) => { - jest.useRealTimers(); - setTimeout(resolve, timeout); - jest.useFakeTimers(); - }); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js index bdc1dde7d48..018303fcae7 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js @@ -319,6 +319,8 @@ describe('AlertsSettingsForm', () => { const validPayloadMsg = payload === emptySamplePayload ? 'not valid' : 'valid'; it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and payload is ${validPayloadMsg}`, async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { payloadExample: payload }, resetPayloadAndMappingConfirmed, @@ -345,6 +347,8 @@ describe('AlertsSettingsForm', () => { : 'was not confirmed'; it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { payloadExample, @@ -359,6 +363,8 @@ describe('AlertsSettingsForm', () => { describe('Parsing payload', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ resetPayloadAndMappingConfirmed: true, }); @@ -456,6 +462,8 @@ describe('AlertsSettingsForm', () => { }); it('should be able to submit when form is dirty', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { type: typeSet.http, name: 'Existing integration' }, }); @@ -466,6 +474,8 @@ describe('AlertsSettingsForm', () => { }); it('should not be able to submit when form is pristine', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentIntegration: { type: typeSet.http, name: 'Existing integration' }, }); diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 5d681c7da4f..28d7ebe28df 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -126,6 +126,8 @@ describe('ProjectsDropdownFilter component', () => { }); it('applies the correct queryParams when making an api call', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm: 'gitlab' }); expect(spyQuery).toHaveBeenCalledTimes(1); @@ -204,6 +206,8 @@ describe('ProjectsDropdownFilter component', () => { await createWithMockDropdown({ multiSelect: true }); selectDropdownItemAtIndex(0); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm: 'this is a very long search string' }); }); diff --git a/spec/frontend/api/packages_api_spec.js b/spec/frontend/api/packages_api_spec.js index 3286dccb1b2..d55d2036dcf 100644 --- a/spec/frontend/api/packages_api_spec.js +++ b/spec/frontend/api/packages_api_spec.js @@ -38,12 +38,17 @@ describe('Api', () => { mock.onPut(expectedUrl).replyOnce(httpStatus.OK, apiResponse); return publishPackage( - { projectPath, name, version: 0, fileName: name, files: [{}] }, + { + projectPath, + name, + version: 0, + fileName: name, + files: [new File(['zip contents'], 'bar.zip')], + }, { status: 'hidden', select: 'package_file' }, ).then(({ data }) => { expect(data).toEqual(apiResponse); - expect(axios.put).toHaveBeenCalledWith(expectedUrl, expect.any(FormData), { - headers: { 'Content-Type': 'multipart/form-data' }, + expect(axios.put).toHaveBeenCalledWith(expectedUrl, expect.any(File), { params: { select: 'package_file', status: 'hidden' }, }); }); diff --git a/spec/frontend/behaviors/copy_to_clipboard_spec.js b/spec/frontend/behaviors/copy_to_clipboard_spec.js new file mode 100644 index 00000000000..c5beaa0ba5d --- /dev/null +++ b/spec/frontend/behaviors/copy_to_clipboard_spec.js @@ -0,0 +1,187 @@ +import initCopyToClipboard, { + CLIPBOARD_SUCCESS_EVENT, + CLIPBOARD_ERROR_EVENT, + I18N_ERROR_MESSAGE, +} from '~/behaviors/copy_to_clipboard'; +import { show, hide, fixTitle, once } from '~/tooltips'; + +let onceCallback = () => {}; +jest.mock('~/tooltips', () => ({ + show: jest.fn(), + hide: jest.fn(), + fixTitle: jest.fn(), + once: jest.fn((event, callback) => { + onceCallback = callback; + }), +})); + +describe('initCopyToClipboard', () => { + let clearSelection; + let focusSpy; + let dispatchEventSpy; + let button; + let clipboardInstance; + + afterEach(() => { + document.body.innerHTML = ''; + clipboardInstance = null; + }); + + const title = 'Copy this value'; + const defaultButtonAttributes = { + 'data-clipboard-text': 'foo bar', + title, + 'data-title': title, + }; + const createButton = (attributes = {}) => { + const combinedAttributes = { ...defaultButtonAttributes, ...attributes }; + button = document.createElement('button'); + Object.keys(combinedAttributes).forEach((attributeName) => { + button.setAttribute(attributeName, combinedAttributes[attributeName]); + }); + document.body.appendChild(button); + }; + + const init = () => { + clipboardInstance = initCopyToClipboard(); + }; + + const setupSpies = () => { + clearSelection = jest.fn(); + focusSpy = jest.spyOn(button, 'focus'); + dispatchEventSpy = jest.spyOn(button, 'dispatchEvent'); + }; + + const emitSuccessEvent = () => { + clipboardInstance.emit('success', { + action: 'copy', + text: 'foo bar', + trigger: button, + clearSelection, + }); + }; + + const emitErrorEvent = () => { + clipboardInstance.emit('error', { + action: 'copy', + text: 'foo bar', + trigger: button, + clearSelection, + }); + }; + + const itHandlesTooltip = (expectedTooltip) => { + it('handles tooltip', () => { + expect(button.getAttribute('title')).toBe(expectedTooltip); + expect(button.getAttribute('aria-label')).toBe(expectedTooltip); + expect(fixTitle).toHaveBeenCalledWith(button); + expect(show).toHaveBeenCalledWith(button); + expect(once).toHaveBeenCalledWith('hidden', expect.any(Function)); + + expect(hide).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(hide).toHaveBeenCalled(); + + onceCallback({ target: button }); + expect(button.getAttribute('title')).toBe(title); + expect(button.getAttribute('aria-label')).toBe(title); + expect(fixTitle).toHaveBeenCalledWith(button); + }); + }; + + describe('when value is successfully copied', () => { + it(`calls clearSelection, focuses the button, and dispatches ${CLIPBOARD_SUCCESS_EVENT} event`, () => { + createButton(); + init(); + setupSpies(); + emitSuccessEvent(); + + expect(clearSelection).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + expect(dispatchEventSpy).toHaveBeenCalledWith(new Event(CLIPBOARD_SUCCESS_EVENT)); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `false`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'false', + }); + init(); + emitSuccessEvent(); + }); + + it('does not handle success tooltip', () => { + expect(show).not.toHaveBeenCalled(); + }); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `true`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'true', + }); + init(); + emitSuccessEvent(); + }); + + itHandlesTooltip('Copied'); + }); + + describe('when `data-clipboard-handle-tooltip` is not set', () => { + beforeEach(() => { + createButton(); + init(); + emitSuccessEvent(); + }); + + itHandlesTooltip('Copied'); + }); + }); + + describe('when there is an error copying the value', () => { + it(`dispatches ${CLIPBOARD_ERROR_EVENT} event`, () => { + createButton(); + init(); + setupSpies(); + emitErrorEvent(); + + expect(dispatchEventSpy).toHaveBeenCalledWith(new Event(CLIPBOARD_ERROR_EVENT)); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `false`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'false', + }); + init(); + emitErrorEvent(); + }); + + it('does not handle error tooltip', () => { + expect(show).not.toHaveBeenCalled(); + }); + }); + + describe('when `data-clipboard-handle-tooltip` is set to `true`', () => { + beforeEach(() => { + createButton({ + 'data-clipboard-handle-tooltip': 'true', + }); + init(); + emitErrorEvent(); + }); + + itHandlesTooltip(I18N_ERROR_MESSAGE); + }); + + describe('when `data-clipboard-handle-tooltip` is not set', () => { + beforeEach(() => { + createButton(); + init(); + emitErrorEvent(); + }); + + itHandlesTooltip(I18N_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index 46a5631b028..d698ee72ea4 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -20,12 +20,6 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` foo/bar/dummy.md </strong> - <small - class="mr-2" - > - a lot - </small> - <clipboard-button-stub category="tertiary" cssclass="btn-clipboard btn-transparent lh-100 position-static" @@ -36,5 +30,13 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` tooltipplacement="top" variant="default" /> + + <small + class="mr-2" + > + a lot + </small> + + <!----> </div> `; diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap index db9684239a1..22bec77276b 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap @@ -17,7 +17,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` </div> <div - class="gl-display-none gl-sm-display-flex" + class="gl-sm-display-flex file-actions" > <viewer-switcher-stub value="simple" diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js index ac3080c65a5..910fc5c946d 100644 --- a/spec/frontend/blob/components/blob_edit_header_spec.js +++ b/spec/frontend/blob/components/blob_edit_header_spec.js @@ -44,6 +44,8 @@ describe('Blob Header Editing', () => { const inputComponent = wrapper.find(GlFormInput); const newValue = 'bar.txt'; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ name: newValue, }); diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js index d935f73c0d1..8220b598ff6 100644 --- a/spec/frontend/blob/components/blob_header_filepath_spec.js +++ b/spec/frontend/blob/components/blob_header_filepath_spec.js @@ -1,3 +1,4 @@ +import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import BlobHeaderFilepath from '~/blob/components/blob_header_filepath.vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -24,6 +25,8 @@ describe('Blob Header Filepath', () => { wrapper.destroy(); }); + const findBadge = () => wrapper.find(GlBadge); + describe('rendering', () => { it('matches the snapshot', () => { createComponent(); @@ -54,6 +57,11 @@ describe('Blob Header Filepath', () => { expect(wrapper.vm.blobSize).toBe('a lot'); }); + it('renders LFS badge if LFS if enabled', () => { + createComponent({ storedExternally: true, externalStorage: 'lfs' }); + expect(findBadge().text()).toBe('LFS'); + }); + it('renders a slot and prepends its contents to the existing one', () => { const slotContent = 'Foo Bar'; createComponent( diff --git a/spec/frontend/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js index 97ae6c0e3b7..330f1f3137e 100644 --- a/spec/frontend/line_highlighter_spec.js +++ b/spec/frontend/blob/line_highlighter_spec.js @@ -1,8 +1,8 @@ /* eslint-disable no-return-assign, no-new, no-underscore-dangle */ import $ from 'jquery'; +import LineHighlighter from '~/blob/line_highlighter'; import * as utils from '~/lib/utils/common_utils'; -import LineHighlighter from '~/line_highlighter'; describe('LineHighlighter', () => { const testContext = {}; diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 061ac7ad167..9e9f866d40c 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -21,6 +21,7 @@ describe('Blob viewer', () => { setTestTimeout(2000); beforeEach(() => { + window.gon.features = { refactorBlobViewer: false }; // This file is based on the old (non-refactored) blob viewer jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); $.fn.extend(jQueryMock); mock = new MockAdapter(axios); diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 5742dfdc5d2..3af173aa18c 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -167,7 +167,7 @@ describe('Board card', () => { mountComponent({ item: { ...mockIssue, isLoading: true } }); expect(wrapper.classes()).toContain('is-disabled'); - expect(wrapper.classes()).not.toContain('user-can-drag'); + expect(wrapper.classes()).not.toContain('gl-cursor-grab'); }); }); @@ -177,7 +177,7 @@ describe('Board card', () => { mountComponent(); expect(wrapper.classes()).not.toContain('is-disabled'); - expect(wrapper.classes()).toContain('user-can-drag'); + expect(wrapper.classes()).toContain('gl-cursor-grab'); }); }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 7b176cea2a3..368c7d561f8 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -9,6 +9,7 @@ import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; +import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; @@ -96,7 +97,7 @@ describe('BoardContentSidebar', () => { }); it('confirms we render MountingPortal', () => { - expect(wrapper.find(MountingPortal).props()).toMatchObject({ + expect(wrapper.findComponent(MountingPortal).props()).toMatchObject({ mountTo: '#js-right-sidebar-portal', append: true, name: 'board-content-sidebar', @@ -141,6 +142,10 @@ describe('BoardContentSidebar', () => { ); }); + it('does not render SidebarSeverity', () => { + expect(wrapper.findComponent(SidebarSeverity).exists()).toBe(false); + }); + describe('when we emit close', () => { let toggleBoardItem; @@ -160,4 +165,17 @@ describe('BoardContentSidebar', () => { }); }); }); + + describe('incident sidebar', () => { + beforeEach(() => { + createStore({ + mockGetters: { activeBoardItem: () => ({ ...mockIssue, epic: null, type: 'INCIDENT' }) }, + }); + createComponent(); + }); + + it('renders SidebarSeverity', () => { + expect(wrapper.findComponent(SidebarSeverity).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index ea551e94f2f..a8398a138ba 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -118,6 +118,7 @@ describe('BoardFilteredSearch', () => { it('sets the url params to the correct results', async () => { const mockFilters = [ { type: 'author', value: { data: 'root', operator: '=' } }, + { type: 'assignee', value: { data: 'root', operator: '=' } }, { type: 'label', value: { data: 'label', operator: '=' } }, { type: 'label', value: { data: 'label2', operator: '=' } }, { type: 'milestone', value: { data: 'New Milestone', operator: '=' } }, @@ -133,7 +134,26 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&assignee_username=root&milestone_title=New+Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + }); + }); + + describe('when assignee is passed a wildcard value', () => { + const url = (arg) => `http://test.host/?assignee_id=${arg}`; + + it.each([ + ['None', url('None')], + ['Any', url('Any')], + ])('sets the url param %s', (assigneeParam, expected) => { + const mockFilters = [{ type: 'assignee', value: { data: assigneeParam, operator: '=' } }]; + jest.spyOn(urlUtility, 'updateHistory'); + findFilteredSearch().vm.$emit('onFilter', mockFilters); + + expect(urlUtility.updateHistory).toHaveBeenCalledWith({ + title: '', + replace: true, + url: expected, + }); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 148d0c5684d..8cc0ad5f30c 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -180,18 +180,18 @@ describe('Board List Header Component', () => { const canDragList = [ListType.label, ListType.milestone, ListType.iteration, ListType.assignee]; it.each(cannotDragList)( - 'does not have user-can-drag-class so user cannot drag list', + 'does not have gl-cursor-grab class so user cannot drag list', (listType) => { createComponent({ listType }); - expect(findTitle().classes()).not.toContain('user-can-drag'); + expect(findTitle().classes()).not.toContain('gl-cursor-grab'); }, ); - it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => { + it.each(canDragList)('has gl-cursor-grab class so user can drag list', (listType) => { createComponent({ listType }); - expect(findTitle().classes()).toContain('user-can-drag'); + expect(findTitle().classes()).toContain('gl-cursor-grab'); }); }); }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index c841c17a029..9cf7c5774bf 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -96,6 +96,8 @@ describe('BoardsSelector', () => { }); wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ [options.loadingKey]: true, }); @@ -161,6 +163,8 @@ describe('BoardsSelector', () => { // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time findDropdown().vm.$emit('show'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingBoards: false, loadingRecentBoards: false, @@ -176,6 +180,8 @@ describe('BoardsSelector', () => { describe('filtering', () => { beforeEach(async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ boards, }); @@ -208,6 +214,8 @@ describe('BoardsSelector', () => { describe('recent boards section', () => { it('shows only when boards are greater than 10', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ boards, }); @@ -217,6 +225,8 @@ describe('BoardsSelector', () => { }); it('does not show when boards are less than 10', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ boards: boards.slice(0, 5), }); @@ -226,6 +236,8 @@ describe('BoardsSelector', () => { }); it('does not show when recentBoards api returns empty array', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ recentBoards: [], }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 51340a3ea4f..7c842d71688 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -29,6 +29,8 @@ import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import projectBoardMilestones from '~/boards/graphql/project_board_milestones.query.graphql'; +import groupBoardMilestones from '~/boards/graphql/group_board_milestones.query.graphql'; import { mockLists, mockListsById, @@ -308,6 +310,36 @@ describe('fetchMilestones', () => { expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type')); }); + it.each([ + [ + 'project', + { + query: projectBoardMilestones, + variables: { fullPath: 'gitlab-org/gitlab', state: 'active' }, + }, + ], + [ + 'group', + { + query: groupBoardMilestones, + variables: { fullPath: 'gitlab-org/gitlab', state: 'active' }, + }, + ], + ])( + 'when boardType is %s it calls fetchMilestones with the correct query and variables', + (boardType, variables) => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + const store = createStore(); + + store.state.boardType = boardType; + + actions.fetchMilestones(store); + + expect(gqlClient.query).toHaveBeenCalledWith(variables); + }, + ); + it('sets milestonesLoading to true', async () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); diff --git a/spec/frontend/branches/branches_delete_modal_spec.js b/spec/frontend/branches/branches_delete_modal_spec.js deleted file mode 100644 index 8b10cca7a11..00000000000 --- a/spec/frontend/branches/branches_delete_modal_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import $ from 'jquery'; -import DeleteModal from '~/branches/branches_delete_modal'; - -describe('branches delete modal', () => { - describe('setDisableDeleteButton', () => { - let submitSpy; - let $deleteButton; - - beforeEach(() => { - setFixtures(` - <div id="modal-delete-branch"> - <form> - <button type="submit" class="js-delete-branch">Delete</button> - </form> - </div> - `); - $deleteButton = $('.js-delete-branch'); - submitSpy = jest.fn((event) => event.preventDefault()); - $('#modal-delete-branch form').on('submit', submitSpy); - // eslint-disable-next-line no-new - new DeleteModal(); - }); - - it('does not submit if button is disabled', () => { - $deleteButton.attr('disabled', true); - - $deleteButton.click(); - - expect(submitSpy).not.toHaveBeenCalled(); - }); - - it('submits if button is not disabled', () => { - $deleteButton.attr('disabled', false); - - $deleteButton.click(); - - expect(submitSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index 70d116c12d3..c4b2927764e 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -66,6 +66,8 @@ describe('CI Lint', () => { it('validate action calls mutation with dry run', async () => { const dryRunEnabled = true; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ dryRun: dryRunEnabled }); findValidateBtn().vm.$emit('click'); diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js index d5a8117f48c..2a3c11f4b47 100644 --- a/spec/frontend/clusters/agents/components/show_spec.js +++ b/spec/frontend/clusters/agents/components/show_spec.js @@ -19,7 +19,7 @@ describe('ClusterAgentShow', () => { let wrapper; useFakeDate([2021, 2, 15]); - const propsData = { + const provide = { agentName: 'cluster-agent', projectPath: 'path/to/project', }; @@ -49,7 +49,7 @@ describe('ClusterAgentShow', () => { shallowMount(ClusterAgentShow, { localVue, apolloProvider, - propsData, + provide, stubs: { GlSprintf, TimeAgoTooltip, GlTab }, }), ); @@ -60,7 +60,7 @@ describe('ClusterAgentShow', () => { wrapper = extendedWrapper( shallowMount(ClusterAgentShow, { - propsData, + provide, mocks: { $apollo, clusterAgent }, slots, stubs: { GlTab }, @@ -85,7 +85,7 @@ describe('ClusterAgentShow', () => { }); it('displays the agent name', () => { - expect(wrapper.text()).toContain(propsData.agentName); + expect(wrapper.text()).toContain(provide.agentName); }); it('displays agent create information', () => { diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js index b129baa2d83..d041cd1e164 100644 --- a/spec/frontend/clusters/forms/components/integration_form_spec.js +++ b/spec/frontend/clusters/forms/components/integration_form_spec.js @@ -82,6 +82,8 @@ describe('ClusterIntegrationForm', () => { .then(() => { // setData is a bad approach because it changes the internal implementation which we should not touch // but our GlFormInput lacks the ability to set a new value. + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ toggleEnabled: !defaultStoreValues.enabled }); }) .then(() => { @@ -93,6 +95,8 @@ describe('ClusterIntegrationForm', () => { return wrapper.vm .$nextTick() .then(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ envScope: `${defaultStoreValues.environmentScope}1` }); }) .then(() => { diff --git a/spec/frontend/clusters_list/components/agent_options_spec.js b/spec/frontend/clusters_list/components/agent_options_spec.js new file mode 100644 index 00000000000..05bab247816 --- /dev/null +++ b/spec/frontend/clusters_list/components/agent_options_spec.js @@ -0,0 +1,211 @@ +import { GlDropdown, GlDropdownItem, GlModal, GlFormInput } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { ENTER_KEY } from '~/lib/utils/keys'; +import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; +import deleteAgentMutation from '~/clusters_list/graphql/mutations/delete_agent.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import AgentOptions from '~/clusters_list/components/agent_options.vue'; +import { MAX_LIST_COUNT } from '~/clusters_list/constants'; +import { getAgentResponse, mockDeleteResponse, mockErrorDeleteResponse } from '../mocks/apollo'; + +Vue.use(VueApollo); + +const projectPath = 'path/to/project'; +const defaultBranchName = 'default'; +const maxAgents = MAX_LIST_COUNT; +const agent = { + id: 'agent-id', + name: 'agent-name', + webPath: 'agent-webPath', +}; + +describe('AgentOptions', () => { + let wrapper; + let toast; + let apolloProvider; + let deleteResponse; + + const findModal = () => wrapper.findComponent(GlModal); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDeleteBtn = () => wrapper.findComponent(GlDropdownItem); + const findInput = () => wrapper.findComponent(GlFormInput); + const findPrimaryAction = () => findModal().props('actionPrimary'); + const findPrimaryActionAttributes = (attr) => findPrimaryAction().attributes[0][attr]; + + const createMockApolloProvider = ({ mutationResponse }) => { + deleteResponse = jest.fn().mockResolvedValue(mutationResponse); + + return createMockApollo([[deleteAgentMutation, deleteResponse]]); + }; + + const writeQuery = () => { + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getAgentsQuery, + variables: { + projectPath, + defaultBranchName, + first: maxAgents, + last: null, + }, + data: getAgentResponse.data, + }); + }; + + const createWrapper = ({ mutationResponse = mockDeleteResponse } = {}) => { + apolloProvider = createMockApolloProvider({ mutationResponse }); + const provide = { + projectPath, + }; + const propsData = { + defaultBranchName, + maxAgents, + agent, + }; + + toast = jest.fn(); + + wrapper = shallowMountExtended(AgentOptions, { + apolloProvider, + provide, + propsData, + mocks: { $toast: { show: toast } }, + stubs: { GlModal }, + }); + wrapper.vm.$refs.modal.hide = jest.fn(); + + writeQuery(); + return wrapper.vm.$nextTick(); + }; + + const submitAgentToDelete = async () => { + findDeleteBtn().vm.$emit('click'); + findInput().vm.$emit('input', agent.name); + await findModal().vm.$emit('primary'); + }; + + beforeEach(() => { + return createWrapper({}); + }); + + afterEach(() => { + wrapper.destroy(); + apolloProvider = null; + deleteResponse = null; + toast = null; + }); + + describe('delete agent action', () => { + it('displays a delete button', () => { + expect(findDeleteBtn().text()).toBe('Delete agent'); + }); + + describe('when clicking the delete button', () => { + beforeEach(() => { + findDeleteBtn().vm.$emit('click'); + }); + + it('displays a delete confirmation modal', () => { + expect(findModal().isVisible()).toBe(true); + }); + }); + + describe.each` + condition | agentName | isDisabled | mutationCalled + ${'the input with agent name is missing'} | ${''} | ${true} | ${false} + ${'the input with agent name is incorrect'} | ${'wrong-name'} | ${true} | ${false} + ${'the input with agent name is correct'} | ${agent.name} | ${false} | ${true} + `('when $condition', ({ agentName, isDisabled, mutationCalled }) => { + beforeEach(() => { + findDeleteBtn().vm.$emit('click'); + findInput().vm.$emit('input', agentName); + }); + + it(`${isDisabled ? 'disables' : 'enables'} the modal primary button`, () => { + expect(findPrimaryActionAttributes('disabled')).toBe(isDisabled); + }); + + describe('when user clicks the modal primary button', () => { + beforeEach(async () => { + await findModal().vm.$emit('primary'); + }); + + if (mutationCalled) { + it('calls the delete mutation', () => { + expect(deleteResponse).toHaveBeenCalledWith({ input: { id: agent.id } }); + }); + } else { + it("doesn't call the delete mutation", () => { + expect(deleteResponse).not.toHaveBeenCalled(); + }); + } + }); + + describe('when user presses the enter button', () => { + beforeEach(async () => { + await findInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + }); + + if (mutationCalled) { + it('calls the delete mutation', () => { + expect(deleteResponse).toHaveBeenCalledWith({ input: { id: agent.id } }); + }); + } else { + it("doesn't call the delete mutation", () => { + expect(deleteResponse).not.toHaveBeenCalled(); + }); + } + }); + }); + + describe('when agent was deleted successfully', () => { + beforeEach(async () => { + await submitAgentToDelete(); + }); + + it('calls the toast action', () => { + expect(toast).toHaveBeenCalledWith(`${agent.name} successfully deleted`); + }); + }); + }); + + describe('when getting an error deleting agent', () => { + beforeEach(async () => { + await createWrapper({ mutationResponse: mockErrorDeleteResponse }); + + submitAgentToDelete(); + }); + + it('displays the error message', () => { + expect(toast).toHaveBeenCalledWith('could not delete agent'); + }); + }); + + describe('when the delete modal was closed', () => { + beforeEach(async () => { + const loadingResponse = new Promise(() => {}); + await createWrapper({ mutationResponse: loadingResponse }); + + submitAgentToDelete(); + }); + + it('reenables the options dropdown', async () => { + expect(findPrimaryActionAttributes('loading')).toBe(true); + expect(findDropdown().attributes('disabled')).toBe('true'); + + await findModal().vm.$emit('hide'); + + expect(findPrimaryActionAttributes('loading')).toBe(false); + expect(findDropdown().attributes('disabled')).toBeUndefined(); + }); + + it('clears the agent name input', async () => { + expect(findInput().attributes('value')).toBe(agent.name); + + await findModal().vm.$emit('hide'); + + expect(findInput().attributes('value')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index a6d76b069cf..887c17bb4ad 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -1,16 +1,22 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import AgentTable from '~/clusters_list/components/agent_table.vue'; +import AgentOptions from '~/clusters_list/components/agent_options.vue'; import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import timeagoMixin from '~/vue_shared/mixins/timeago'; const connectedTimeNow = new Date(); const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME); +const provideData = { + projectPath: 'path/to/project', +}; const propsData = { agents: [ { name: 'agent-1', + id: 'agent-1-id', configFolder: { webPath: '/agent/full/path', }, @@ -21,6 +27,7 @@ const propsData = { }, { name: 'agent-2', + id: 'agent-2-id', webPath: '/agent-2', status: 'active', lastContact: connectedTimeNow.getTime(), @@ -34,6 +41,7 @@ const propsData = { }, { name: 'agent-3', + id: 'agent-3-id', webPath: '/agent-3', status: 'inactive', lastContact: connectedTimeInactive.getTime(), @@ -48,6 +56,10 @@ const propsData = { ], }; +const AgentOptionsStub = stubComponent(AgentOptions, { + template: `<div></div>`, +}); + describe('AgentTable', () => { let wrapper; @@ -57,15 +69,21 @@ describe('AgentTable', () => { const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at); const findConfiguration = (at) => wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); + const findAgentOptions = () => wrapper.findAllComponents(AgentOptions); beforeEach(() => { - wrapper = mountExtended(AgentTable, { propsData }); + wrapper = mountExtended(AgentTable, { + propsData, + provide: provideData, + stubs: { + AgentOptions: AgentOptionsStub, + }, + }); }); afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); @@ -108,5 +126,9 @@ describe('AgentTable', () => { expect(findLink.exists()).toBe(hasLink); expect(findConfiguration(lineNumber).text()).toBe(agentPath); }); + + it('displays actions menu for each agent', () => { + expect(findAgentOptions()).toHaveLength(3); + }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index a34202c789d..9af25a534d8 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -272,6 +272,8 @@ describe('Clusters', () => { describe('when updating currentPage', () => { beforeEach(() => { mockPollingApi(200, apiData, paginationHeader(totalSecondPage, perPage, 2)); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentPage: 2 }); return axios.waitForAll(); }); diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index 804f9834506..c4a31ed4394 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -75,3 +75,15 @@ export const getAgentResponse = { }, }, }; + +export const mockDeleteResponse = { + data: { clusterAgentDelete: { errors: [] } }, +}; + +export const mockErrorDeleteResponse = { + data: { + clusterAgentDelete: { + errors: ['could not delete agent'], + }, + }, +}; diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index c376b58cc72..e209f628aa2 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -92,6 +92,8 @@ describe('Pipelines table in Commits and Merge requests', () => { it('should make an API request when using pagination', async () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ store: { state: { diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js index de8f8efd260..415f1314a36 100644 --- a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js +++ b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js @@ -26,6 +26,11 @@ describe('content/components/wrappers/frontmatter', () => { expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); }); + it('adds content-editor-code-block class to the pre element', () => { + createWrapper(); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('content-editor-code-block'); + }); + it('renders a node-view-content as a code element', () => { createWrapper(); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 6a0a0c76825..05fa0f79ef0 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -36,4 +36,10 @@ describe('content_editor/extensions/code_block_highlight', () => { expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); }); + + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); diff --git a/spec/frontend/content_editor/extensions/code_spec.js b/spec/frontend/content_editor/extensions/code_spec.js new file mode 100644 index 00000000000..0a54ac6a96b --- /dev/null +++ b/spec/frontend/content_editor/extensions/code_spec.js @@ -0,0 +1,8 @@ +import Code from '~/content_editor/extensions/code'; +import { EXTENSION_PRIORITY_LOWER } from '~/content_editor/constants'; + +describe('content_editor/extensions/code', () => { + it('has a lower loading priority', () => { + expect(Code.config.priority).toBe(EXTENSION_PRIORITY_LOWER); + }); +}); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js index 517f6947b9a..a8cbad6ef81 100644 --- a/spec/frontend/content_editor/extensions/frontmatter_spec.js +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -1,30 +1,47 @@ import Frontmatter from '~/content_editor/extensions/frontmatter'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/frontmatter', () => { let tiptapEditor; let doc; - let p; + let frontmatter; + let codeBlock; beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [Frontmatter] }); + tiptapEditor = createTestEditor({ extensions: [Frontmatter, CodeBlockHighlight] }); ({ - builders: { doc, p }, + builders: { doc, codeBlock, frontmatter }, } = createDocBuilder({ tiptapEditor, names: { frontmatter: { nodeType: Frontmatter.name }, + codeBlock: { nodeType: CodeBlockHighlight.name }, }, })); }); it('does not insert a frontmatter block when executing code block input rule', () => { - const expectedDoc = doc(p('')); + const expectedDoc = doc(codeBlock('')); const inputRuleText = '``` '; triggerNodeInputRule({ tiptapEditor, inputRuleText }); expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); + + it.each` + command | result | resultDesc + ${'toggleCodeBlock'} | ${() => doc(codeBlock(''))} | ${'code block element'} + ${'setCodeBlock'} | ${() => doc(codeBlock(''))} | ${'code block element'} + ${'setFrontmatter'} | ${() => doc(frontmatter(''))} | ${'frontmatter element'} + ${'toggleFrontmatter'} | ${() => doc(frontmatter(''))} | ${'frontmatter element'} + `('executing $command should generate a document with a $resultDesc', ({ command, result }) => { + const expectedDoc = result(); + + tiptapEditor.commands[command](); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); }); diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js new file mode 100644 index 00000000000..256f7bad309 --- /dev/null +++ b/spec/frontend/content_editor/extensions/image_spec.js @@ -0,0 +1,41 @@ +import Image from '~/content_editor/extensions/image'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/image', () => { + let tiptapEditor; + let doc; + let p; + let image; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Image] }); + + ({ + builders: { doc, p, image }, + } = createDocBuilder({ + tiptapEditor, + names: { + image: { nodeType: Image.name }, + }, + })); + }); + + it('adds data-canonical-src attribute when rendering to HTML', () => { + const initialDoc = doc( + p( + image({ + canonicalSrc: 'uploads/image.jpg', + src: '/-/wikis/uploads/image.jpg', + alt: 'image', + title: 'this is an image', + }), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + expect(tiptapEditor.getHTML()).toEqual( + '<p><img src="/-/wikis/uploads/image.jpg" alt="image" title="this is an image" data-canonical-src="uploads/image.jpg"></p>', + ); + }); +}); diff --git a/spec/frontend/content_editor/extensions/link_spec.js b/spec/frontend/content_editor/extensions/link_spec.js index ead898554d1..bb841357d37 100644 --- a/spec/frontend/content_editor/extensions/link_spec.js +++ b/spec/frontend/content_editor/extensions/link_spec.js @@ -33,7 +33,7 @@ describe('content_editor/extensions/link', () => { ${'documentation](readme.md'} | ${() => p('documentation](readme.md')} ${'http://example.com '} | ${() => p(link({ href: 'http://example.com' }, 'http://example.com'))} ${'https://example.com '} | ${() => p(link({ href: 'https://example.com' }, 'https://example.com'))} - ${'www.example.com '} | ${() => p(link({ href: 'www.example.com' }, 'www.example.com'))} + ${'www.example.com '} | ${() => p(link({ href: 'http://www.example.com' }, 'www.example.com'))} ${'example.com/ab.html '} | ${() => p('example.com/ab.html')} ${'https://www.google.com '} | ${() => p(link({ href: 'https://www.google.com' }, 'https://www.google.com'))} `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 97f6d8f6334..01d4c994e88 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -164,6 +164,17 @@ describe('markdownSerializer', () => { expect(serialize(paragraph(italic('italics')))).toBe('_italics_'); }); + it('correctly serializes code blocks wrapped by italics and bold marks', () => { + const text = 'code block'; + + expect(serialize(paragraph(italic(code(text))))).toBe(`_\`${text}\`_`); + expect(serialize(paragraph(code(italic(text))))).toBe(`_\`${text}\`_`); + expect(serialize(paragraph(bold(code(text))))).toBe(`**\`${text}\`**`); + expect(serialize(paragraph(code(bold(text))))).toBe(`**\`${text}\`**`); + expect(serialize(paragraph(strike(code(text))))).toBe(`~~\`${text}\`~~`); + expect(serialize(paragraph(code(strike(text))))).toBe(`~~\`${text}\`~~`); + }); + it('correctly serializes inline diff', () => { expect( serialize( @@ -341,6 +352,10 @@ this is not really json but just trying out whether this case works or not ); }); + it('does not serialize an image when src and canonicalSrc are empty', () => { + expect(serialize(paragraph(image({})))).toBe(''); + }); + it('correctly serializes an image with a title', () => { expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe( '![foo bar](img.jpg "baz")', diff --git a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js index 0c6095e601f..4e92fa1df16 100644 --- a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js @@ -206,6 +206,8 @@ describe('ClusterFormDropdown', () => { const searchQuery = secondItem.name; wrapper.setProps({ items }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchQuery }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js index d866ffd4efb..a0510d46794 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js @@ -67,6 +67,8 @@ describe('ServiceCredentialsForm', () => { }); it('enables submit button when role ARN is not provided', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ roleArn: '123' }); return vm.vm.$nextTick().then(() => { @@ -75,6 +77,8 @@ describe('ServiceCredentialsForm', () => { }); it('dispatches createRole action when submit button is clicked', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ roleArn: '123' }); // set role ARN to enable button findSubmitButton().vm.$emit('click', new Event('click')); @@ -84,6 +88,8 @@ describe('ServiceCredentialsForm', () => { describe('when is creating role', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ roleArn: '123' }); // set role ARN to enable button state.isCreatingRole = true; diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js index 8f4903dd91b..2b6f2134553 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js @@ -79,6 +79,8 @@ describe('GkeMachineTypeDropdown', () => { store = createStore(); wrapper = createComponent(store); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: true }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js index b191b107609..2b0acc8cf5d 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js @@ -83,6 +83,8 @@ describe('GkeProjectIdDropdown', () => { it('returns default toggle text', () => { bootstrap(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: false }); return wrapper.vm.$nextTick().then(() => { @@ -99,6 +101,8 @@ describe('GkeProjectIdDropdown', () => { hasProject: () => true, }, ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: false }); return wrapper.vm.$nextTick().then(() => { @@ -110,6 +114,8 @@ describe('GkeProjectIdDropdown', () => { bootstrap({ projects: null, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: false }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js index 4054b768e34..22fc681f863 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_zone_dropdown_spec.js @@ -47,6 +47,8 @@ describe('GkeZoneDropdown', () => { describe('isLoading', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: true }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js index b2753ad8cf5..0edab4f5ec5 100644 --- a/spec/frontend/crm/contact_form_spec.js +++ b/spec/frontend/crm/contact_form_spec.js @@ -112,7 +112,7 @@ describe('Customer relations contact form component', () => { await waitForPromises(); expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('Phone is invalid.'); + expect(findError().text()).toBe('create contact is invalid.'); }); }); @@ -151,7 +151,7 @@ describe('Customer relations contact form component', () => { await waitForPromises(); expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('Email is invalid.'); + expect(findError().text()).toBe('update contact is invalid.'); }); }); }); diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js new file mode 100644 index 00000000000..0e3abc05c37 --- /dev/null +++ b/spec/frontend/crm/form_spec.js @@ -0,0 +1,278 @@ +import { GlAlert } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Form from '~/crm/components/form.vue'; +import routes from '~/crm/routes'; +import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; +import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; +import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql'; +import { + createContactMutationErrorResponse, + createContactMutationResponse, + getGroupContactsQueryResponse, + updateContactMutationErrorResponse, + updateContactMutationResponse, + createOrganizationMutationErrorResponse, + createOrganizationMutationResponse, + getGroupOrganizationsQueryResponse, +} from './mock_data'; + +const FORM_CREATE_CONTACT = 'create contact'; +const FORM_UPDATE_CONTACT = 'update contact'; +const FORM_CREATE_ORG = 'create organization'; + +describe('Reusable form component', () => { + Vue.use(VueApollo); + Vue.use(VueRouter); + + const DEFAULT_RESPONSES = { + createContact: Promise.resolve(createContactMutationResponse), + updateContact: Promise.resolve(updateContactMutationResponse), + createOrg: Promise.resolve(createOrganizationMutationResponse), + }; + + let wrapper; + let handler; + let fakeApollo; + let router; + + beforeEach(() => { + router = new VueRouter({ + base: '', + mode: 'history', + routes, + }); + router.push('/test'); + + handler = jest.fn().mockImplementation((key) => DEFAULT_RESPONSES[key]); + + const hanlderWithKey = (key) => (...args) => handler(key, ...args); + + fakeApollo = createMockApollo([ + [createContactMutation, hanlderWithKey('createContact')], + [updateContactMutation, hanlderWithKey('updateContact')], + [createOrganizationMutation, hanlderWithKey('createOrg')], + ]); + + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + data: getGroupContactsQueryResponse.data, + }); + + fakeApollo.clients.defaultClient.cache.writeQuery({ + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + data: getGroupOrganizationsQueryResponse.data, + }); + }); + + const mockToastShow = jest.fn(); + + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findForm = () => wrapper.find('form'); + const findError = () => wrapper.findComponent(GlAlert); + + const mountComponent = (propsData) => { + wrapper = shallowMountExtended(Form, { + router, + apolloProvider: fakeApollo, + propsData: { drawerOpen: true, ...propsData }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + }; + + const mountContact = ({ propsData } = {}) => { + mountComponent({ + fields: [ + { name: 'firstName', label: 'First name', required: true }, + { name: 'lastName', label: 'Last name', required: true }, + { name: 'email', label: 'Email', required: true }, + { name: 'phone', label: 'Phone' }, + { name: 'description', label: 'Description' }, + ], + ...propsData, + }); + }; + + const mountContactCreate = () => { + const propsData = { + title: 'New contact', + successMessage: 'Contact has been added', + buttonLabel: 'Create contact', + getQuery: { + query: getGroupContactsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.contacts', + mutation: createContactMutation, + additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, + }; + mountContact({ propsData }); + }; + + const mountContactUpdate = () => { + const propsData = { + title: 'Edit contact', + successMessage: 'Contact has been updated', + mutation: updateContactMutation, + existingModel: { + id: 'gid://gitlab/CustomerRelations::Contact/12', + firstName: 'First', + lastName: 'Last', + email: 'email@example.com', + }, + }; + mountContact({ propsData }); + }; + + const mountOrganization = ({ propsData } = {}) => { + mountComponent({ + fields: [ + { name: 'name', label: 'Name', required: true }, + { name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } }, + { name: 'description', label: 'Description' }, + ], + ...propsData, + }); + }; + + const mountOrganizationCreate = () => { + const propsData = { + title: 'New organization', + successMessage: 'Organization has been added', + buttonLabel: 'Create organization', + getQuery: { + query: getGroupOrganizationsQuery, + variables: { groupFullPath: 'flightjs' }, + }, + getQueryNodePath: 'group.organizations', + mutation: createOrganizationMutation, + additionalCreateParams: { groupId: 'gid://gitlab/Group/26' }, + }; + mountOrganization({ propsData }); + }; + + const forms = { + [FORM_CREATE_CONTACT]: { + mountFunction: mountContactCreate, + mutationErrorResponse: createContactMutationErrorResponse, + toastMessage: 'Contact has been added', + }, + [FORM_UPDATE_CONTACT]: { + mountFunction: mountContactUpdate, + mutationErrorResponse: updateContactMutationErrorResponse, + toastMessage: 'Contact has been updated', + }, + [FORM_CREATE_ORG]: { + mountFunction: mountOrganizationCreate, + mutationErrorResponse: createOrganizationMutationErrorResponse, + toastMessage: 'Organization has been added', + }, + }; + const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT))( + '%s form save button', + (name, { mountFunction }) => { + beforeEach(() => { + mountFunction(); + }); + + it('should be disabled when required fields are empty', async () => { + wrapper.find('#firstName').vm.$emit('input', ''); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(true); + }); + + it('should not be disabled when required fields have values', async () => { + wrapper.find('#firstName').vm.$emit('input', 'A'); + wrapper.find('#lastName').vm.$emit('input', 'B'); + wrapper.find('#email').vm.$emit('input', 'C'); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(false); + }); + }, + ); + + describe.each(asTestParams(FORM_CREATE_ORG))('%s form save button', (name, { mountFunction }) => { + beforeEach(() => { + mountFunction(); + }); + + it('should be disabled when required field is empty', async () => { + wrapper.find('#name').vm.$emit('input', ''); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(true); + }); + + it('should not be disabled when required field has a value', async () => { + wrapper.find('#name').vm.$emit('input', 'A'); + await waitForPromises(); + + expect(findSaveButton().props('disabled')).toBe(false); + }); + }); + + describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT, FORM_CREATE_ORG))( + 'when %s mutation is successful', + (name, { mountFunction, toastMessage }) => { + it('form should display correct toast message', async () => { + mountFunction(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(mockToastShow).toHaveBeenCalledWith(toastMessage); + }); + }, + ); + + describe.each(asTestParams(FORM_CREATE_CONTACT, FORM_UPDATE_CONTACT, FORM_CREATE_ORG))( + 'when %s mutation fails', + (formName, { mutationErrorResponse, mountFunction }) => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(); + }); + + it('should show error on reject', async () => { + handler.mockRejectedValue('ERROR'); + + mountFunction(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().text()).toBe('Something went wrong. Please try again.'); + }); + + it('should show error on error response', async () => { + handler.mockResolvedValue(mutationErrorResponse); + + mountFunction(); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().text()).toBe(`${formName} is invalid.`); + }); + }, + ); +}); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js index f7af2ccdb72..e351e101b29 100644 --- a/spec/frontend/crm/mock_data.js +++ b/spec/frontend/crm/mock_data.js @@ -82,7 +82,6 @@ export const getGroupOrganizationsQueryResponse = { export const createContactMutationResponse = { data: { customerRelationsContactCreate: { - __typeName: 'CustomerRelationsContactCreatePayload', contact: { __typename: 'CustomerRelationsContact', id: 'gid://gitlab/CustomerRelations::Contact/1', @@ -102,7 +101,7 @@ export const createContactMutationErrorResponse = { data: { customerRelationsContactCreate: { contact: null, - errors: ['Phone is invalid.'], + errors: ['create contact is invalid.'], }, }, }; @@ -130,7 +129,7 @@ export const updateContactMutationErrorResponse = { data: { customerRelationsContactUpdate: { contact: null, - errors: ['Email is invalid.'], + errors: ['update contact is invalid.'], }, }, }; @@ -138,7 +137,6 @@ export const updateContactMutationErrorResponse = { export const createOrganizationMutationResponse = { data: { customerRelationsOrganizationCreate: { - __typeName: 'CustomerRelationsOrganizationCreatePayload', organization: { __typename: 'CustomerRelationsOrganization', id: 'gid://gitlab/CustomerRelations::Organization/2', @@ -155,7 +153,7 @@ export const createOrganizationMutationErrorResponse = { data: { customerRelationsOrganizationCreate: { organization: null, - errors: ['Name cannot be blank.'], + errors: ['create organization is invalid.'], }, }, }; diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js index 976b626f35f..0a7909774c9 100644 --- a/spec/frontend/crm/new_organization_form_spec.js +++ b/spec/frontend/crm/new_organization_form_spec.js @@ -103,7 +103,7 @@ describe('Customer relations organizations root app', () => { await waitForPromises(); expect(findError().exists()).toBe(true); - expect(findError().text()).toBe('Name cannot be blank.'); + expect(findError().text()).toBe('create organization is invalid.'); }); }); }); diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/cycle_analytics/stage_table_spec.js index 3158446c37d..9605dce2668 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/cycle_analytics/stage_table_spec.js @@ -24,6 +24,7 @@ const findTable = () => wrapper.findComponent(GlTable); const findTableHead = () => wrapper.find('thead'); const findTableHeadColumns = () => findTableHead().findAll('th'); const findStageEventTitle = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-title'); +const findStageEventLink = (ev) => extendedWrapper(ev).findByTestId('vsa-stage-event-link'); const findStageTime = () => wrapper.findByTestId('vsa-stage-event-time'); const findIcon = (name) => wrapper.findByTestId(`${name}-icon`); @@ -86,6 +87,15 @@ describe('StageTable', () => { expect(titles[index]).toBe(ev.title); }); }); + + it('will not display the project name in the record link', () => { + const evs = findStageEvents(); + + const links = evs.wrappers.map((ev) => findStageEventLink(ev).text()); + issueEventItems.forEach((ev, index) => { + expect(links[index]).toBe(`#${ev.iid}`); + }); + }); }); describe('default event', () => { @@ -187,6 +197,53 @@ describe('StageTable', () => { }); }); + describe('includeProjectName set', () => { + const fakenamespace = 'some/fake/path'; + beforeEach(() => { + wrapper = createComponent({ includeProjectName: true }); + }); + + it('will display the project name in the record link', () => { + const evs = findStageEvents(); + + const links = evs.wrappers.map((ev) => findStageEventLink(ev).text()); + issueEventItems.forEach((ev, index) => { + expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`); + }); + }); + + describe.each` + namespaceFullPath | hasFullPath + ${'fake'} | ${false} + ${fakenamespace} | ${true} + `('with a namespace', ({ namespaceFullPath, hasFullPath }) => { + let evs = null; + let links = null; + + beforeEach(() => { + wrapper = createComponent({ + includeProjectName: true, + stageEvents: issueEventItems.map((ie) => ({ ...ie, namespaceFullPath })), + }); + + evs = findStageEvents(); + links = evs.wrappers.map((ev) => findStageEventLink(ev).text()); + }); + + it(`with namespaceFullPath='${namespaceFullPath}' ${ + hasFullPath ? 'will' : 'does not' + } include the namespace`, () => { + issueEventItems.forEach((ev, index) => { + if (hasFullPath) { + expect(links[index]).toBe(`${namespaceFullPath}/${ev.projectPath}#${ev.iid}`); + } else { + expect(links[index]).toBe(`${ev.projectPath}#${ev.iid}`); + } + }); + }); + }); + }); + describe('Pagination', () => { beforeEach(() => { wrapper = createComponent(); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js index c97e4845bc2..082db2cc312 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -63,6 +63,8 @@ describe('ValueStreamMetrics', () => { it('renders hidden GlSingleStat components for each metric', async () => { await waitForPromises(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isLoading: true }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js index 4dd5c29a917..5f4d4071f29 100644 --- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js +++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js @@ -26,6 +26,8 @@ describe('Deploy freeze timezone dropdown', () => { }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm }); }; diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_signed_out_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_signed_out_spec.js.snap new file mode 100644 index 00000000000..ab37cb90bd3 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_signed_out_spec.js.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DesignNoteSignedOut renders message containing register and sign-in links while user wants to reply to a discussion 1`] = ` +<div + class="disabled-comment text-center" +> + Please + <gl-link-stub + href="/users/sign_up?redirect_to_referer=yes" + > + register + </gl-link-stub> + or + <gl-link-stub + href="/users/sign_in?redirect_to_referer=yes" + > + sign in + </gl-link-stub> + to reply. +</div> +`; + +exports[`DesignNoteSignedOut renders message containing register and sign-in links while user wants to start a new discussion 1`] = ` +<div + class="disabled-comment text-center" +> + Please + <gl-link-stub + href="/users/sign_up?redirect_to_referer=yes" + > + register + </gl-link-stub> + or + <gl-link-stub + href="/users/sign_in?redirect_to_referer=yes" + > + sign in + </gl-link-stub> + to start a new discussion. +</div> +`; diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 9335d800a16..e816a05ba53 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -1,7 +1,9 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { ApolloMutation } from 'vue-apollo'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; +import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue'; import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql'; @@ -20,6 +22,7 @@ const defaultMockDiscussion = { const DEFAULT_TODO_COUNT = 2; describe('Design discussions component', () => { + const originalGon = window.gon; let wrapper; const findDesignNotes = () => wrapper.findAll(DesignNote); @@ -31,6 +34,7 @@ describe('Design discussions component', () => { const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); + const findApolloMutation = () => wrapper.findComponent(ApolloMutation); const mutationVariables = { mutation: createNoteMutation, @@ -42,6 +46,8 @@ describe('Design discussions component', () => { }, }, }; + const registerPath = '/users/sign_up?redirect_to_referer=yes'; + const signInPath = '/users/sign_in?redirect_to_referer=yes'; const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } }); const readQuery = jest.fn().mockReturnValue({ project: { @@ -62,6 +68,8 @@ describe('Design discussions component', () => { designId: 'design-id', discussionIndex: 1, discussionWithOpenForm: '', + registerPath, + signInPath, ...props, }, data() { @@ -88,8 +96,13 @@ describe('Design discussions component', () => { }); } + beforeEach(() => { + window.gon = { current_user_id: 1 }; + }); + afterEach(() => { wrapper.destroy(); + window.gon = originalGon; }); describe('when discussion is not resolvable', () => { @@ -349,4 +362,41 @@ describe('Design discussions component', () => { expect(wrapper.emitted('open-form')).toBeTruthy(); }); + + describe('when user is not logged in', () => { + const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut); + + beforeEach(() => { + window.gon = { current_user_id: null }; + createComponent( + { + discussion: { + ...defaultMockDiscussion, + }, + discussionWithOpenForm: defaultMockDiscussion.id, + }, + { discussionComment: 'test', isFormRendered: true }, + ); + }); + + it('does not render resolve discussion button', () => { + expect(findResolveButton().exists()).toBe(false); + }); + + it('does not render replace-placeholder component', () => { + expect(findReplyPlaceholder().exists()).toBe(false); + }); + + it('does not render apollo-mutation component', () => { + expect(findApolloMutation().exists()).toBe(false); + }); + + it('renders design-note-signed-out component', () => { + expect(findDesignNoteSignedOut().exists()).toBe(true); + expect(findDesignNoteSignedOut().props()).toMatchObject({ + registerPath, + signInPath, + }); + }); + }); }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js b/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js new file mode 100644 index 00000000000..e71bb5ab520 --- /dev/null +++ b/spec/frontend/design_management/components/design_notes/design_note_signed_out_spec.js @@ -0,0 +1,36 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; + +function createComponent(isAddDiscussion = false) { + return shallowMount(DesignNoteSignedOut, { + propsData: { + registerPath: '/users/sign_up?redirect_to_referer=yes', + signInPath: '/users/sign_in?redirect_to_referer=yes', + isAddDiscussion, + }, + stubs: { + GlSprintf, + }, + }); +} + +describe('DesignNoteSignedOut', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders message containing register and sign-in links while user wants to reply to a discussion', () => { + wrapper = createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders message containing register and sign-in links while user wants to start a new discussion', () => { + wrapper = createComponent(true); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index d3119be7159..4bda5054090 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -117,6 +117,8 @@ describe('Design overlay component', () => { it.each([notes[0].discussion.notes.nodes[1], notes[0].discussion.notes.nodes[0]])( 'should not apply inactive class to the pin for the active discussion', (note) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ activeDiscussion: { id: note.id, @@ -131,6 +133,8 @@ describe('Design overlay component', () => { ); it('should apply inactive class to all pins besides the active one', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ activeDiscussion: { id: notes[0].id, @@ -212,6 +216,8 @@ describe('Design overlay component', () => { const { position } = note; const newCoordinates = { x: 20, y: 20 }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ movingNoteNewPosition: { ...position, @@ -345,6 +351,8 @@ describe('Design overlay component', () => { }); const newCoordinates = { x: 20, y: 20 }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ movingNoteStartPosition: { ...notes[0].position, @@ -368,6 +376,8 @@ describe('Design overlay component', () => { it('should calculate delta correctly from state', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ movingNoteStartPosition: { clientX: 10, diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js index edf8b965153..adec9ef469d 100644 --- a/spec/frontend/design_management/components/design_presentation_spec.js +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -15,6 +15,7 @@ const mockOverlayData = { }; describe('Design management design presentation component', () => { + const originalGon = window.gon; let wrapper; function createComponent( @@ -39,6 +40,8 @@ describe('Design management design presentation component', () => { stubs, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); wrapper.element.scrollTo = jest.fn(); } @@ -113,8 +116,13 @@ describe('Design management design presentation component', () => { }); } + beforeEach(() => { + window.gon = { current_user_id: 1 }; + }); + afterEach(() => { wrapper.destroy(); + window.gon = originalGon; }); it('renders image and overlay when image provided', () => { @@ -550,4 +558,23 @@ describe('Design management design presentation component', () => { }); }); }); + + describe('when user is not logged in', () => { + beforeEach(() => { + window.gon = { current_user_id: null }; + createComponent( + { + image: 'test.jpg', + imageName: 'test', + }, + mockOverlayData, + ); + }); + + it('disables commenting from design overlay', () => { + expect(wrapper.findComponent(DesignOverlay).props()).toMatchObject({ + disableCommenting: true, + }); + }); + }); }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index 8eb993ec7b5..4cd71bdb7f3 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -2,6 +2,7 @@ import { GlCollapse, GlPopover } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Cookies from 'js-cookie'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; +import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; import DesignTodoButton from '~/design_management/components/design_todo_button.vue'; import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; @@ -30,6 +31,7 @@ const cookieKey = 'hide_design_resolved_comments_popover'; const mutate = jest.fn().mockResolvedValue(); describe('Design management design sidebar component', () => { + const originalGon = window.gon; let wrapper; const findDiscussions = () => wrapper.findAll(DesignDiscussion); @@ -58,11 +60,20 @@ describe('Design management design sidebar component', () => { }, }, stubs: { GlPopover }, + provide: { + registerPath: '/users/sign_up?redirect_to_referer=yes', + signInPath: '/users/sign_in?redirect_to_referer=yes', + }, }); } + beforeEach(() => { + window.gon = { current_user_id: 1 }; + }); + afterEach(() => { wrapper.destroy(); + window.gon = originalGon; }); it('renders participants', () => { @@ -248,4 +259,44 @@ describe('Design management design sidebar component', () => { expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); }); }); + + describe('when user is not logged in', () => { + const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut); + + beforeEach(() => { + window.gon = { current_user_id: null }; + }); + + describe('design has no discussions', () => { + beforeEach(() => { + createComponent({ + design: { + ...design, + discussions: { + nodes: [], + }, + }, + }); + }); + + it('does not render a message about possibility to create a new discussion', () => { + expect(findNewDiscussionDisclaimer().exists()).toBe(false); + }); + + it('renders design-note-signed-out component', () => { + expect(findDesignNoteSignedOut().exists()).toBe(true); + }); + }); + + describe('design has discussions', () => { + beforeEach(() => { + Cookies.set(cookieKey, true); + createComponent(); + }); + + it('renders design-note-signed-out component', () => { + expect(findDesignNoteSignedOut().exists()).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js index 765d902f9a6..ac3afc73c86 100644 --- a/spec/frontend/design_management/components/image_spec.js +++ b/spec/frontend/design_management/components/image_spec.js @@ -9,6 +9,8 @@ describe('Design management large image component', () => { wrapper = shallowMount(DesignImage, { propsData, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); } diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js index 1d9b9c002f9..6e0592984a2 100644 --- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js +++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js @@ -42,6 +42,8 @@ describe('Design management pagination component', () => { }); it('renders navigation buttons', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ designCollection: { designs: [{ id: '1' }, { id: '2' }] }, }); @@ -53,6 +55,8 @@ describe('Design management pagination component', () => { describe('keyboard buttons navigation', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] }, }); diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js index 009ffe57744..cf872046f53 100644 --- a/spec/frontend/design_management/components/toolbar/index_spec.js +++ b/spec/frontend/design_management/components/toolbar/index_spec.js @@ -48,6 +48,8 @@ describe('Design management toolbar component', () => { }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ permissions: { createDesign, diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index ebfe27eaa71..a4fb671ae13 100644 --- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -34,6 +34,8 @@ describe('Design management design version dropdown component', () => { stubs: { GlSprintf }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions, }); diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 3d04840b1f8..31b3117cb6c 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -70,6 +70,13 @@ exports[`Design management design index page renders design index 1`] = ` <!----> + <design-note-signed-out-stub + class="gl-mb-4" + isadddiscussion="true" + registerpath="" + signinpath="" + /> + <design-discussion-stub data-testid="unresolved-discussion" designid="gid::/gitlab/Design/1" @@ -77,6 +84,8 @@ exports[`Design management design index page renders design index 1`] = ` discussionwithopenform="" markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" noteableid="gid::/gitlab/Design/1" + registerpath="" + signinpath="" /> <gl-button-stub @@ -126,6 +135,8 @@ exports[`Design management design index page renders design index 1`] = ` discussionwithopenform="" markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" noteableid="gid::/gitlab/Design/1" + registerpath="" + signinpath="" /> </gl-collapse-stub> @@ -231,14 +242,14 @@ exports[`Design management design index page with error GlAlert is rendered in c participants="[object Object]" /> - <h2 - class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" - data-testid="new-discussion-disclaimer" - > - - Click the image where you'd like to start a new discussion - - </h2> + <!----> + + <design-note-signed-out-stub + class="gl-mb-4" + isadddiscussion="true" + registerpath="" + signinpath="" + /> <!----> diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 6ce384b4869..98e2313e9f2 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -317,6 +317,8 @@ describe('Design management design index page', () => { describe('when no design exists for given version', () => { it('redirects to /designs', () => { createComponent({ loading: true }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ allVersions: mockAllVersions, }); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 427161a391b..dd0f7972553 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -91,6 +91,8 @@ const designToMove = { }; describe('Design management index page', () => { + const registerPath = '/users/sign_up?redirect_to_referer=yes'; + const signInPath = '/users/sign_in?redirect_to_referer=yes'; let mutate; let wrapper; let fakeApollo; @@ -164,6 +166,8 @@ describe('Design management index page', () => { provide: { projectPath: 'project-path', issueIid: '1', + registerPath, + signInPath, }, }); } @@ -186,6 +190,10 @@ describe('Design management index page', () => { apolloProvider: fakeApollo, router, stubs: { VueDraggable }, + provide: { + registerPath, + signInPath, + }, }); } @@ -204,6 +212,8 @@ describe('Design management index page', () => { it('renders error', async () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: true }); await nextTick(); @@ -381,6 +391,8 @@ describe('Design management index page', () => { it('updates state appropriately after upload complete', async () => { createComponent({ stubs: { GlEmptyState } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse); @@ -393,6 +405,8 @@ describe('Design management index page', () => { it('updates state appropriately after upload error', async () => { createComponent({ stubs: { GlEmptyState } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); wrapper.vm.onUploadDesignError(); diff --git a/spec/frontend/diffs/components/image_diff_overlay_spec.js b/spec/frontend/diffs/components/image_diff_overlay_spec.js index 47b144b2387..8c1a8041f6c 100644 --- a/spec/frontend/diffs/components/image_diff_overlay_spec.js +++ b/spec/frontend/diffs/components/image_diff_overlay_spec.js @@ -6,8 +6,8 @@ import { imageDiffDiscussions } from '../mock_data/diff_discussions'; describe('Diffs image diff overlay component', () => { const dimensions = { - width: 100, - height: 200, + width: 99.9, + height: 199.5, }; let wrapper; let dispatch; @@ -38,7 +38,6 @@ describe('Diffs image diff overlay component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); it('renders comment badges', () => { @@ -81,17 +80,21 @@ describe('Diffs image diff overlay component', () => { it('dispatches openDiffFileCommentForm when clicking overlay', () => { createComponent({ canComment: true }); - wrapper.find('.js-add-image-diff-note-button').trigger('click', { offsetX: 0, offsetY: 0 }); + wrapper.find('.js-add-image-diff-note-button').trigger('click', { offsetX: 1.2, offsetY: 3.8 }); expect(dispatch).toHaveBeenCalledWith('diffs/openDiffFileCommentForm', { fileHash: 'ABC', - x: 0, - y: 0, + x: 1, + y: 4, width: 100, height: 200, - xPercent: 0, - yPercent: 0, + xPercent: expect.any(Number), + yPercent: expect.any(Number), }); + + const { xPercent, yPercent } = dispatch.mock.calls[0][1]; + expect(xPercent).toBeCloseTo(0.6); + expect(yPercent).toBeCloseTo(1.9); }); describe('toggle discussion', () => { diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index bc53202c919..049cab3a83b 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -342,27 +342,30 @@ describe('Base editor', () => { describe('implementation', () => { let instance; - beforeEach(() => { - instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); - }); it('correctly proxies value from the model', () => { + instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); expect(instance.getValue()).toBe(blobContent); }); - it('emits the EDITOR_READY_EVENT event after setting up the instance', () => { + it('emits the EDITOR_READY_EVENT event passing the instance after setting it up', () => { jest.spyOn(monacoEditor, 'create').mockImplementation(() => { return { setModel: jest.fn(), onDidDispose: jest.fn(), layout: jest.fn(), + dispose: jest.fn(), }; }); - const eventSpy = jest.fn(); + let passedInstance; + const eventSpy = jest.fn().mockImplementation((ev) => { + passedInstance = ev.detail.instance; + }); editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy); expect(eventSpy).not.toHaveBeenCalled(); - editor.createInstance({ el: editorEl }); + instance = editor.createInstance({ el: editorEl }); expect(eventSpy).toHaveBeenCalled(); + expect(passedInstance).toBe(instance); }); }); diff --git a/spec/frontend/emoji/components/category_spec.js b/spec/frontend/emoji/components/category_spec.js index afd36a1eb88..82dc0cdc250 100644 --- a/spec/frontend/emoji/components/category_spec.js +++ b/spec/frontend/emoji/components/category_spec.js @@ -26,6 +26,8 @@ describe('Emoji category component', () => { }); it('renders group', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ renderGroup: true }); expect(wrapper.find(EmojiGroup).attributes('rendergroup')).toBe('true'); diff --git a/spec/frontend/emoji/components/emoji_list_spec.js b/spec/frontend/emoji/components/emoji_list_spec.js index 9dc73ef191e..a72ba614d9f 100644 --- a/spec/frontend/emoji/components/emoji_list_spec.js +++ b/spec/frontend/emoji/components/emoji_list_spec.js @@ -28,6 +28,8 @@ async function factory(render, propsData = { searchValue: '' }) { await nextTick(); if (render) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ render: true }); // Wait for component to render diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js index b699f953945..b8dcb7c0d08 100644 --- a/spec/frontend/environments/confirm_rollback_modal_spec.js +++ b/spec/frontend/environments/confirm_rollback_modal_spec.js @@ -26,7 +26,7 @@ describe('Confirm Rollback Modal Component', () => { commit: { shortId: 'abc0123', }, - 'last?': true, + isLast: true, }, modalId: 'test', }; @@ -145,7 +145,7 @@ describe('Confirm Rollback Modal Component', () => { ...environment, lastDeployment: { ...environment.lastDeployment, - 'last?': false, + isLast: false, }, }, hasMultipleCommits, @@ -167,7 +167,7 @@ describe('Confirm Rollback Modal Component', () => { ...environment, lastDeployment: { ...environment.lastDeployment, - 'last?': false, + isLast: false, }, }, hasMultipleCommits, @@ -191,7 +191,7 @@ describe('Confirm Rollback Modal Component', () => { ...environment, lastDeployment: { ...environment.lastDeployment, - 'last?': true, + isLast: true, }, }, hasMultipleCommits, diff --git a/spec/frontend/environments/deployment_spec.js b/spec/frontend/environments/deployment_spec.js new file mode 100644 index 00000000000..37209bdc86c --- /dev/null +++ b/spec/frontend/environments/deployment_spec.js @@ -0,0 +1,29 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import Deployment from '~/environments/components/deployment.vue'; +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; +import { resolvedEnvironment } from './graphql/mock_data'; + +describe('~/environments/components/deployment.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(Deployment, { + propsData: { + deployment: resolvedEnvironment.lastDeployment, + ...propsData, + }, + }); + + afterEach(() => { + wrapper?.destroy(); + }); + + describe('status', () => { + it('should pass the deployable status to the badge', () => { + wrapper = createWrapper(); + expect(wrapper.findComponent(DeploymentStatusBadge).props('status')).toBe( + resolvedEnvironment.lastDeployment.status, + ); + }); + }); +}); diff --git a/spec/frontend/environments/deployment_status_badge_spec.js b/spec/frontend/environments/deployment_status_badge_spec.js new file mode 100644 index 00000000000..02aae57396a --- /dev/null +++ b/spec/frontend/environments/deployment_status_badge_spec.js @@ -0,0 +1,42 @@ +import { GlBadge } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { s__ } from '~/locale'; +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; + +describe('~/environments/components/deployment_status_badge.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(DeploymentStatusBadge, { + propsData, + }); + + describe.each` + status | text | variant | icon + ${'created'} | ${s__('Deployment|Created')} | ${'neutral'} | ${'status_created'} + ${'running'} | ${s__('Deployment|Running')} | ${'info'} | ${'status_running'} + ${'success'} | ${s__('Deployment|Success')} | ${'success'} | ${'status_success'} + ${'failed'} | ${s__('Deployment|Failed')} | ${'danger'} | ${'status_failed'} + ${'canceled'} | ${s__('Deployment|Cancelled')} | ${'neutral'} | ${'status_canceled'} + ${'skipped'} | ${s__('Deployment|Skipped')} | ${'neutral'} | ${'status_skipped'} + ${'blocked'} | ${s__('Deployment|Waiting')} | ${'neutral'} | ${'status_manual'} + `('$status', ({ status, text, variant, icon }) => { + let badge; + + beforeEach(() => { + wrapper = createWrapper({ propsData: { status } }); + badge = wrapper.findComponent(GlBadge); + }); + + it(`sets the text to ${text}`, () => { + expect(wrapper.text()).toBe(text); + }); + + it(`sets the variant to ${variant}`, () => { + expect(badge.props('variant')).toBe(variant); + }); + it(`sets the icon to ${icon}`, () => { + expect(badge.props('icon')).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index db78a6b0cdd..1b68a692db8 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,9 +1,13 @@ import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { TEST_HOST } from 'helpers/test_constants'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; import eventHub from '~/environments/event_hub'; +import actionMutation from '~/environments/graphql/mutations/action.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; const scheduledJobAction = { name: 'scheduled action', @@ -25,12 +29,13 @@ describe('EnvironmentActions Component', () => { const findEnvironmentActionsButton = () => wrapper.find('[data-testid="environment-actions-button"]'); - function createComponent(props, { mountFn = shallowMount } = {}) { + function createComponent(props, { mountFn = shallowMount, options = {} } = {}) { wrapper = mountFn(EnvironmentActions, { propsData: { actions: [], ...props }, directives: { GlTooltip: createMockDirective(), }, + ...options, }); } @@ -150,4 +155,32 @@ describe('EnvironmentActions Component', () => { expect(findDropdownItem(expiredJobAction).text()).toContain('00:00:00'); }); }); + + describe('graphql', () => { + Vue.use(VueApollo); + + const action = { + name: 'bar', + play_path: 'https://gitlab.com/play', + }; + + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApollo(); + createComponent( + { actions: [action], graphql: true }, + { options: { apolloProvider: mockApollo } }, + ); + }); + + it('should trigger a graphql mutation on click', () => { + jest.spyOn(mockApollo.defaultClient, 'mutate'); + findDropdownItem(action).vm.$emit('click'); + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: actionMutation, + variables: { action }, + }); + }); + }); }); diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js index dff444b79f3..358abca2f77 100644 --- a/spec/frontend/environments/environment_stop_spec.js +++ b/spec/frontend/environments/environment_stop_spec.js @@ -1,38 +1,80 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import setEnvironmentToStopMutation from '~/environments/graphql/mutations/set_environment_to_stop.mutation.graphql'; +import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql'; import StopComponent from '~/environments/components/environment_stop.vue'; import eventHub from '~/environments/event_hub'; - -$.fn.tooltip = () => {}; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { resolvedEnvironment } from './graphql/mock_data'; describe('Stop Component', () => { let wrapper; - const createWrapper = () => { + const createWrapper = (props = {}, options = {}) => { wrapper = shallowMount(StopComponent, { propsData: { environment: {}, + ...props, }, + ...options, }); }; const findButton = () => wrapper.find(GlButton); - beforeEach(() => { - jest.spyOn(window, 'confirm'); + describe('eventHub', () => { + beforeEach(() => { + createWrapper(); + }); - createWrapper(); - }); + it('should render a button to stop the environment', () => { + expect(findButton().exists()).toBe(true); + expect(wrapper.attributes('title')).toEqual('Stop environment'); + }); - it('should render a button to stop the environment', () => { - expect(findButton().exists()).toBe(true); - expect(wrapper.attributes('title')).toEqual('Stop environment'); + it('emits requestStopEnvironment in the event hub when button is clicked', () => { + jest.spyOn(eventHub, '$emit'); + findButton().vm.$emit('click'); + expect(eventHub.$emit).toHaveBeenCalledWith('requestStopEnvironment', wrapper.vm.environment); + }); }); - it('emits requestStopEnvironment in the event hub when button is clicked', () => { - jest.spyOn(eventHub, '$emit'); - findButton().vm.$emit('click'); - expect(eventHub.$emit).toHaveBeenCalledWith('requestStopEnvironment', wrapper.vm.environment); + describe('graphql', () => { + Vue.use(VueApollo); + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.writeQuery({ + query: isEnvironmentStoppingQuery, + variables: { environment: resolvedEnvironment }, + data: { isEnvironmentStopping: true }, + }); + + createWrapper( + { graphql: true, environment: resolvedEnvironment }, + { apolloProvider: mockApollo }, + ); + }); + + it('should render a button to stop the environment', () => { + expect(findButton().exists()).toBe(true); + expect(wrapper.attributes('title')).toEqual('Stop environment'); + }); + + it('sets the environment to stop on click', () => { + jest.spyOn(mockApollo.defaultClient, 'mutate'); + findButton().vm.$emit('click'); + expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({ + mutation: setEnvironmentToStopMutation, + variables: { environment: resolvedEnvironment }, + }); + }); + + it('should show a loading icon if the environment is currently stopping', async () => { + expect(findButton().props('loading')).toBe(true); + }); }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index e75d3ac0321..fce30973547 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -477,7 +477,141 @@ export const resolvedEnvironment = { externalUrl: 'https://example.org', environmentType: 'review', nameWithoutType: 'hello', - lastDeployment: null, + lastDeployment: { + id: 78, + iid: 24, + sha: 'f3ba6dd84f8f891373e9b869135622b954852db1', + ref: { name: 'main', refPath: '/h5bp/html5-boilerplate/-/tree/main' }, + status: 'success', + createdAt: '2022-01-07T15:47:27.415Z', + deployedAt: '2022-01-07T15:47:32.450Z', + tag: false, + isLast: true, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gck.test:3000/root', + showStatus: false, + path: '/root', + }, + deployable: { + id: 1014, + name: 'deploy-prod', + started: '2022-01-07T15:47:31.037Z', + complete: true, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/1014', + retryPath: '/h5bp/html5-boilerplate/-/jobs/1014/retry', + playable: false, + scheduled: false, + createdAt: '2022-01-07T15:47:27.404Z', + updatedAt: '2022-01-07T15:47:32.341Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/1014', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/h5bp/html5-boilerplate/-/jobs/1014/retry', + method: 'post', + buttonTitle: 'Retry this job', + }, + }, + }, + commit: { + id: 'f3ba6dd84f8f891373e9b869135622b954852db1', + shortId: 'f3ba6dd8', + createdAt: '2022-01-07T15:47:26.000+00:00', + parentIds: ['3213b6ac17afab99be37d5d38f38c6c8407387cc'], + title: 'Update .gitlab-ci.yml file', + message: 'Update .gitlab-ci.yml file', + authorName: 'Administrator', + authorEmail: 'admin@example.com', + authoredDate: '2022-01-07T15:47:26.000+00:00', + committerName: 'Administrator', + committerEmail: 'admin@example.com', + committedDate: '2022-01-07T15:47:26.000+00:00', + trailers: {}, + webUrl: + 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + author: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webUrl: 'http://gck.test:3000/root', + showStatus: false, + path: '/root', + }, + authorGravatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commitUrl: + 'http://gck.test:3000/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + commitPath: '/h5bp/html5-boilerplate/-/commit/f3ba6dd84f8f891373e9b869135622b954852db1', + }, + manualActions: [ + { + id: 1015, + name: 'deploy-staging', + started: null, + complete: false, + archived: false, + buildPath: '/h5bp/html5-boilerplate/-/jobs/1015', + playPath: '/h5bp/html5-boilerplate/-/jobs/1015/play', + playable: true, + scheduled: false, + createdAt: '2022-01-07T15:47:27.422Z', + updatedAt: '2022-01-07T15:47:28.557Z', + status: { + icon: 'status_manual', + text: 'manual', + label: 'manual play action', + group: 'manual', + tooltip: 'manual action', + hasDetails: true, + detailsPath: '/h5bp/html5-boilerplate/-/jobs/1015', + illustration: { + image: + '/assets/illustrations/manual_action-c55aee2c5f9ebe9f72751480af8bb307be1a6f35552f344cc6d1bf979d3422f6.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job requires manual intervention to start. Before starting this job, you can add variables below for last-minute configuration changes.', + }, + favicon: + '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', + action: { + icon: 'play', + title: 'Play', + path: '/h5bp/html5-boilerplate/-/jobs/1015/play', + method: 'post', + buttonTitle: 'Trigger this manual action', + }, + }, + }, + ], + scheduledActions: [], + cluster: null, + }, hasStopAction: false, rolloutStatus: null, environmentPath: '/h5bp/html5-boilerplate/-/environments/41', diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js index d8d26b74504..6b53dc24f0f 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers_spec.js @@ -1,8 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; +import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { resolvers } from '~/environments/graphql/resolvers'; import environmentToRollback from '~/environments/graphql/queries/environment_to_rollback.query.graphql'; import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql'; +import environmentToStopQuery from '~/environments/graphql/queries/environment_to_stop.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql'; import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; @@ -210,4 +212,36 @@ describe('~/frontend/environments/graphql/resolvers', () => { }); }); }); + describe('setEnvironmentToStop', () => { + it('should write the given environment to the cache', () => { + localState.client.writeQuery = jest.fn(); + mockResolvers.Mutation.setEnvironmentToStop( + null, + { environment: resolvedEnvironment }, + localState, + ); + + expect(localState.client.writeQuery).toHaveBeenCalledWith({ + query: environmentToStopQuery, + data: { environmentToStop: resolvedEnvironment }, + }); + }); + }); + describe('action', () => { + it('should POST to the given path', async () => { + mock.onPost(ENDPOINT).reply(200); + const errors = await mockResolvers.Mutation.action(null, { action: { playPath: ENDPOINT } }); + + expect(errors).toEqual({ __typename: 'LocalEnvironmentErrors', errors: [] }); + }); + it('should return a nice error message on fail', async () => { + mock.onPost(ENDPOINT).reply(500); + const errors = await mockResolvers.Mutation.action(null, { action: { playPath: ENDPOINT } }); + + expect(errors).toEqual({ + __typename: 'LocalEnvironmentErrors', + errors: [s__('Environments|An error occurred while making the request.')], + }); + }); + }); }); diff --git a/spec/frontend/environments/new_environment_folder_spec.js b/spec/frontend/environments/new_environment_folder_spec.js index 27d27d5869a..6823c88a5a1 100644 --- a/spec/frontend/environments/new_environment_folder_spec.js +++ b/spec/frontend/environments/new_environment_folder_spec.js @@ -1,10 +1,13 @@ import VueApollo from 'vue-apollo'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import { GlCollapse, GlIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubTransition } from 'helpers/stub_transition'; import { __, s__ } from '~/locale'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; +import EnvironmentItem from '~/environments/components/new_environment_item.vue'; import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; Vue.use(VueApollo); @@ -25,13 +28,20 @@ describe('~/environments/components/new_environments_folder.vue', () => { }; const createWrapper = (propsData, apolloProvider) => - mountExtended(EnvironmentsFolder, { apolloProvider, propsData }); + mountExtended(EnvironmentsFolder, { + apolloProvider, + propsData, + stubs: { transition: stubTransition() }, + }); - beforeEach(() => { + beforeEach(async () => { environmentFolderMock = jest.fn(); [nestedEnvironment] = resolvedEnvironmentsApp.environments; environmentFolderMock.mockReturnValue(resolvedFolder); wrapper = createWrapper({ nestedEnvironment }, createApolloProvider()); + + await nextTick(); + await waitForPromises(); folderName = wrapper.findByText(nestedEnvironment.name); button = wrapper.findByRole('button', { name: __('Expand') }); }); @@ -57,7 +67,8 @@ describe('~/environments/components/new_environments_folder.vue', () => { const link = findLink(); expect(collapse.attributes('visible')).toBeUndefined(); - expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-right', 'folder-o']); + const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); + expect(iconNames).toEqual(['angle-right', 'folder-o']); expect(folderName.classes('gl-font-weight-bold')).toBe(false); expect(link.exists()).toBe(false); }); @@ -68,10 +79,21 @@ describe('~/environments/components/new_environments_folder.vue', () => { const link = findLink(); expect(button.attributes('aria-label')).toBe(__('Collapse')); - expect(collapse.attributes('visible')).toBe('true'); - expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-down', 'folder-open']); + expect(collapse.attributes('visible')).toBe('visible'); + const iconNames = icons.wrappers.map((i) => i.props('name')).slice(0, 2); + expect(iconNames).toEqual(['angle-down', 'folder-open']); expect(folderName.classes('gl-font-weight-bold')).toBe(true); expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath); }); + + it('displays all environments when opened', async () => { + await button.trigger('click'); + + const names = resolvedFolder.environments.map((e) => + expect.stringMatching(e.nameWithoutType), + ); + const environments = wrapper.findAllComponents(EnvironmentItem).wrappers.map((w) => w.text()); + expect(environments).toEqual(expect.arrayContaining(names)); + }); }); }); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js new file mode 100644 index 00000000000..244aef5c43b --- /dev/null +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -0,0 +1,341 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { GlCollapse, GlIcon } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubTransition } from 'helpers/stub_transition'; +import { __, s__ } from '~/locale'; +import EnvironmentItem from '~/environments/components/new_environment_item.vue'; +import Deployment from '~/environments/components/deployment.vue'; +import { resolvedEnvironment } from './graphql/mock_data'; + +Vue.use(VueApollo); + +describe('~/environments/components/new_environment_item.vue', () => { + let wrapper; + + const createApolloProvider = () => { + return createMockApollo(); + }; + + const createWrapper = ({ propsData = {}, apolloProvider } = {}) => + mountExtended(EnvironmentItem, { + apolloProvider, + propsData: { environment: resolvedEnvironment, ...propsData }, + stubs: { transition: stubTransition() }, + }); + + const findDeployment = () => wrapper.findComponent(Deployment); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('displays the name when not in a folder', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const name = wrapper.findByRole('link', { name: resolvedEnvironment.name }); + expect(name.exists()).toBe(true); + }); + + it('displays the name minus the folder prefix when in a folder', () => { + wrapper = createWrapper({ + propsData: { inFolder: true }, + apolloProvider: createApolloProvider(), + }); + + const name = wrapper.findByRole('link', { name: resolvedEnvironment.nameWithoutType }); + expect(name.exists()).toBe(true); + }); + + it('truncates the name if it is very long', () => { + const environment = { + ...resolvedEnvironment, + name: + 'this is a really long name that should be truncated because otherwise it would look strange in the UI', + }; + wrapper = createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() }); + + const name = wrapper.findByRole('link', { + name: (text) => environment.name.startsWith(text.slice(0, -1)), + }); + expect(name.exists()).toBe(true); + expect(name.text()).toHaveLength(80); + }); + + describe('url', () => { + it('shows a link for the url if one is present', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const url = wrapper.findByRole('link', { name: s__('Environments|Open live environment') }); + + expect(url.attributes('href')).toEqual(resolvedEnvironment.externalUrl); + }); + + it('does not show a link for the url if one is missing', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, externalUrl: '' } }, + apolloProvider: createApolloProvider(), + }); + + const url = wrapper.findByRole('link', { name: s__('Environments|Open live environment') }); + + expect(url.exists()).toBe(false); + }); + }); + + describe('actions', () => { + it('shows a dropdown if there are actions to perform', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); + + expect(actions.exists()).toBe(true); + }); + + it('does not show a dropdown if there are no actions to perform', () => { + wrapper = createWrapper({ + propsData: { + environment: { + ...resolvedEnvironment, + lastDeployment: null, + }, + apolloProvider: createApolloProvider(), + }, + }); + + const actions = wrapper.findByRole('button', { name: __('Deploy to...') }); + + expect(actions.exists()).toBe(false); + }); + + it('passes all the actions down to the action component', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const action = wrapper.findByRole('menuitem', { name: 'deploy-staging' }); + + expect(action.exists()).toBe(true); + }); + }); + + describe('stop', () => { + it('shows a buton to stop the environment if the environment is available', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const stop = wrapper.findByRole('button', { name: s__('Environments|Stop environment') }); + + expect(stop.exists()).toBe(true); + }); + + it('does not show a buton to stop the environment if the environment is stopped', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, canStop: false } }, + apolloProvider: createApolloProvider(), + }); + + const stop = wrapper.findByRole('button', { name: s__('Environments|Stop environment') }); + + expect(stop.exists()).toBe(false); + }); + }); + + describe('rollback', () => { + it('shows the option to rollback/re-deploy if available', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Re-deploy to environment'), + }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the option to rollback/re-deploy if not available', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, lastDeployment: null } }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Re-deploy to environment'), + }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('pin', () => { + it('shows the option to pin the environment if there is an autostop date', () => { + wrapper = createWrapper({ + propsData: { + environment: { ...resolvedEnvironment, autoStopAt: new Date(Date.now() + 100000) }, + }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the option to pin the environment if there is no autostop date', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Prevent auto-stopping') }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('monitoring', () => { + it('shows the link to monitoring if metrics are set up', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, metricsPath: '/metrics' } }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the link to monitoring if metrics are not set up', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Monitoring') }); + + expect(rollback.exists()).toBe(false); + }); + }); + describe('terminal', () => { + it('shows the link to the terminal if set up', () => { + wrapper = createWrapper({ + propsData: { environment: { ...resolvedEnvironment, terminalPath: '/terminal' } }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the link to the terminal if not set up', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { name: __('Terminal') }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('delete', () => { + it('shows the button to delete the environment if possible', () => { + wrapper = createWrapper({ + propsData: { + environment: { ...resolvedEnvironment, canDelete: true, deletePath: '/terminal' }, + }, + apolloProvider: createApolloProvider(), + }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Delete environment'), + }); + + expect(rollback.exists()).toBe(true); + }); + + it('does not show the button to delete the environment if not possible', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const rollback = wrapper.findByRole('menuitem', { + name: s__('Environments|Delete environment'), + }); + + expect(rollback.exists()).toBe(false); + }); + }); + + describe('collapse', () => { + let icon; + let collapse; + let button; + let environmentName; + + beforeEach(() => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + collapse = wrapper.findComponent(GlCollapse); + icon = wrapper.findComponent(GlIcon); + button = wrapper.findByRole('button', { name: __('Expand') }); + environmentName = wrapper.findByText(resolvedEnvironment.name); + }); + + it('is collapsed by default', () => { + expect(collapse.attributes('visible')).toBeUndefined(); + expect(icon.props('name')).toEqual('angle-right'); + expect(environmentName.classes('gl-font-weight-bold')).toBe(false); + }); + + it('opens on click', async () => { + expect(findDeployment().isVisible()).toBe(false); + + await button.trigger('click'); + + expect(button.attributes('aria-label')).toBe(__('Collapse')); + expect(collapse.attributes('visible')).toBe('visible'); + expect(icon.props('name')).toEqual('angle-down'); + expect(environmentName.classes('gl-font-weight-bold')).toBe(true); + expect(findDeployment().isVisible()).toBe(true); + }); + }); + describe('last deployment', () => { + it('should pass the last deployment to the deployment component when it exists', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const deployment = findDeployment(); + expect(deployment.props('deployment')).toEqual(resolvedEnvironment.lastDeployment); + }); + it('should not show the last deployment when it is missing', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: null, + }; + + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const deployment = findDeployment(); + expect(deployment.exists()).toBe(false); + }); + }); + + describe('upcoming deployment', () => { + it('should pass the upcoming deployment to the deployment component when it exists', () => { + const upcomingDeployment = resolvedEnvironment.lastDeployment; + const environment = { ...resolvedEnvironment, lastDeployment: null, upcomingDeployment }; + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const deployment = findDeployment(); + expect(deployment.props('deployment')).toEqual(upcomingDeployment); + }); + it('should not show the upcoming deployment when it is missing', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: null, + upcomingDeployment: null, + }; + + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const deployment = findDeployment(); + expect(deployment.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js index 1e9bd4d64c9..c9eccc26694 100644 --- a/spec/frontend/environments/new_environments_app_spec.js +++ b/spec/frontend/environments/new_environments_app_spec.js @@ -8,7 +8,9 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { sprintf, __, s__ } from '~/locale'; import EnvironmentsApp from '~/environments/components/new_environments_app.vue'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; -import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; +import EnvironmentsItem from '~/environments/components/new_environment_item.vue'; +import StopEnvironmentModal from '~/environments/components/stop_environment_modal.vue'; +import { resolvedEnvironmentsApp, resolvedFolder, resolvedEnvironment } from './graphql/mock_data'; Vue.use(VueApollo); @@ -17,6 +19,7 @@ describe('~/environments/components/new_environments_app.vue', () => { let environmentAppMock; let environmentFolderMock; let paginationMock; + let environmentToStopMock; const createApolloProvider = () => { const mockResolvers = { @@ -24,6 +27,7 @@ describe('~/environments/components/new_environments_app.vue', () => { environmentApp: environmentAppMock, folder: environmentFolderMock, pageInfo: paginationMock, + environmentToStop: environmentToStopMock, }, }; @@ -45,6 +49,7 @@ describe('~/environments/components/new_environments_app.vue', () => { provide = {}, environmentsApp, folder, + environmentToStop = {}, pageInfo = { total: 20, perPage: 5, @@ -58,6 +63,7 @@ describe('~/environments/components/new_environments_app.vue', () => { environmentAppMock.mockReturnValue(environmentsApp); environmentFolderMock.mockReturnValue(folder); paginationMock.mockReturnValue(pageInfo); + environmentToStopMock.mockReturnValue(environmentToStop); const apolloProvider = createApolloProvider(); wrapper = createWrapper({ apolloProvider, provide }); @@ -68,6 +74,7 @@ describe('~/environments/components/new_environments_app.vue', () => { beforeEach(() => { environmentAppMock = jest.fn(); environmentFolderMock = jest.fn(); + environmentToStopMock = jest.fn(); paginationMock = jest.fn(); }); @@ -87,6 +94,18 @@ describe('~/environments/components/new_environments_app.vue', () => { expect(text).not.toContainEqual(expect.stringMatching('production')); }); + it('should show all the environments that are fetched', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + }); + + const text = wrapper.findAllComponents(EnvironmentsItem).wrappers.map((w) => w.text()); + + expect(text).not.toContainEqual(expect.stringMatching('review')); + expect(text).toContainEqual(expect.stringMatching('production')); + }); + it('should show a button to create a new environment', async () => { await createWrapperWithMocked({ environmentsApp: resolvedEnvironmentsApp, @@ -168,13 +187,27 @@ describe('~/environments/components/new_environments_app.vue', () => { expect(environmentAppMock).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ scope: 'stopped' }), + expect.objectContaining({ scope: 'stopped', page: 1 }), expect.anything(), expect.anything(), ); }); }); + describe('modals', () => { + it('should pass the environment to stop to the stop environment modal', async () => { + await createWrapperWithMocked({ + environmentsApp: resolvedEnvironmentsApp, + folder: resolvedFolder, + environmentToStop: resolvedEnvironment, + }); + + const modal = wrapper.findComponent(StopEnvironmentModal); + + expect(modal.props('environment')).toMatchObject(resolvedEnvironment); + }); + }); + describe('pagination', () => { it('should sync page from query params on load', async () => { await createWrapperWithMocked({ diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 4e459d800e8..77f51193258 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -173,6 +173,8 @@ describe('ErrorDetails', () => { beforeEach(() => { mocks.$apollo.queries.error.loading = false; mountComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { id: 'gid://gitlab/Gitlab::ErrorTracking::DetailedError/129381', @@ -203,6 +205,8 @@ describe('ErrorDetails', () => { const culprit = '<script>console.log("surprise!")</script>'; beforeEach(() => { store.state.details.loadingStacktrace = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { culprit, @@ -222,6 +226,8 @@ describe('ErrorDetails', () => { describe('Badges', () => { it('should show language and error level badges', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: 'error', logger: 'ruby' }, @@ -233,6 +239,8 @@ describe('ErrorDetails', () => { }); it('should NOT show the badge if the tag is not present', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: 'error' }, @@ -246,6 +254,8 @@ describe('ErrorDetails', () => { it.each(Object.keys(severityLevel))( 'should set correct severity level variant for %s badge', (level) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: severityLevel[level] }, @@ -260,6 +270,8 @@ describe('ErrorDetails', () => { ); it('should fallback for ERROR severityLevelVariant when severityLevel is unknown', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { tags: { level: 'someNewErrorLevel' }, @@ -408,6 +420,8 @@ describe('ErrorDetails', () => { it('should show alert with closed issueId', () => { const closedIssueId = 123; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isAlertVisible: true, closedIssueId, @@ -429,6 +443,8 @@ describe('ErrorDetails', () => { describe('is present', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabIssuePath, @@ -451,6 +467,8 @@ describe('ErrorDetails', () => { describe('is not present', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabIssuePath: null, @@ -480,6 +498,8 @@ describe('ErrorDetails', () => { it('should display a link', () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabCommit, @@ -493,6 +513,8 @@ describe('ErrorDetails', () => { it('should not display a link', () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { gitlabCommit: null, @@ -519,6 +541,8 @@ describe('ErrorDetails', () => { it('should display links to Sentry', async () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ error: { firstReleaseVersion, @@ -535,6 +559,8 @@ describe('ErrorDetails', () => { it('should display links to GitLab when integrated', async () => { mocks.$apollo.queries.error.loading = false; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ error: { firstReleaseVersion, @@ -557,6 +583,8 @@ describe('ErrorDetails', () => { jest.spyOn(Tracking, 'event'); mocks.$apollo.queries.error.loading = false; mountComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ error: { externalUrl }, }); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index c0c542ae587..74d5731bbea 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -396,6 +396,8 @@ describe('ErrorTrackingList', () => { GlPagination: false, }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ pageValue: 2 }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index bfdeee0881b..35a7ff4eb07 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -12,6 +12,7 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control render_views before do + stub_feature_flags(refactor_blob_viewer: false) # This fixture is only used by the legacy (non-refactored) blob viewer sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index fa150fbf57c..36e6cf72750 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -24,80 +24,109 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end - describe GraphQL::Query, type: :request do - get_runners_query_name = 'get_runners.query.graphql' - + describe do before do sign_in(admin) enable_admin_mode!(admin) end - let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") - end + describe GraphQL::Query, type: :request do + get_runners_query_name = 'get_runners.query.graphql' - it "#{fixtures_path}#{get_runners_query_name}.json" do - post_graphql(query, current_user: admin, variables: {}) + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") + end - expect_graphql_errors_to_be_empty - end + it "#{fixtures_path}#{get_runners_query_name}.json" do + post_graphql(query, current_user: admin, variables: {}) - it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do - post_graphql(query, current_user: admin, variables: { first: 2 }) + expect_graphql_errors_to_be_empty + end - expect_graphql_errors_to_be_empty + it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do + post_graphql(query, current_user: admin, variables: { first: 2 }) + + expect_graphql_errors_to_be_empty + end end - end - describe GraphQL::Query, type: :request do - get_runner_query_name = 'get_runner.query.graphql' + describe GraphQL::Query, type: :request do + get_runners_count_query_name = 'get_runners_count.query.graphql' - before do - sign_in(admin) - enable_admin_mode!(admin) - end + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runners_count_query_name}") + end + + it "#{fixtures_path}#{get_runners_count_query_name}.json" do + post_graphql(query, current_user: admin, variables: {}) - let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") + expect_graphql_errors_to_be_empty + end end - it "#{fixtures_path}#{get_runner_query_name}.json" do - post_graphql(query, current_user: admin, variables: { - id: instance_runner.to_global_id.to_s - }) + describe GraphQL::Query, type: :request do + get_runner_query_name = 'get_runner.query.graphql' - expect_graphql_errors_to_be_empty + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") + end + + it "#{fixtures_path}#{get_runner_query_name}.json" do + post_graphql(query, current_user: admin, variables: { + id: instance_runner.to_global_id.to_s + }) + + expect_graphql_errors_to_be_empty + end end end - describe GraphQL::Query, type: :request do - get_group_runners_query_name = 'get_group_runners.query.graphql' - + describe do let_it_be(:group_owner) { create(:user) } before do group.add_owner(group_owner) end - let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") - end + describe GraphQL::Query, type: :request do + get_group_runners_query_name = 'get_group_runners.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") + end - it "#{fixtures_path}#{get_group_runners_query_name}.json" do - post_graphql(query, current_user: group_owner, variables: { - groupFullPath: group.full_path - }) + it "#{fixtures_path}#{get_group_runners_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) - expect_graphql_errors_to_be_empty + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path, + first: 1 + }) + + expect_graphql_errors_to_be_empty + end end - it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do - post_graphql(query, current_user: group_owner, variables: { - groupFullPath: group.full_path, - first: 1 - }) + describe GraphQL::Query, type: :request do + get_group_runners_count_query_name = 'get_group_runners_count.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_count_query_name}") + end + + it "#{fixtures_path}#{get_group_runners_count_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) - expect_graphql_errors_to_be_empty + expect_graphql_errors_to_be_empty + end end end end diff --git a/spec/frontend/fixtures/static/project_select_combo_button.html b/spec/frontend/fixtures/static/project_select_combo_button.html index 444e0bc84a2..3776610ed4c 100644 --- a/spec/frontend/fixtures/static/project_select_combo_button.html +++ b/spec/frontend/fixtures/static/project_select_combo_button.html @@ -1,6 +1,6 @@ <div class="project-item-select-holder"> <input class="project-item-select" data-group-id="12345" data-relative-path="issues/new" /> - <a class="new-project-item-link" data-label="New issue" data-type="issues" href=""> + <a class="js-new-project-item-link" data-label="issue" data-type="issues" href=""> <span class="gl-spinner"></span> </a> <a class="new-project-item-select-button"> diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index fc736f2d155..d5451ec2064 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,9 +1,12 @@ import * as Sentry from '@sentry/browser'; +import { setHTMLFixture } from 'helpers/fixtures'; import createFlash, { hideFlash, addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, + createAlert, + VARIANT_WARNING, } from '~/flash'; jest.mock('@sentry/browser'); @@ -68,6 +71,236 @@ describe('Flash', () => { }); }); + describe('createAlert', () => { + const mockMessage = 'a message'; + let alert; + + describe('no flash-container', () => { + it('does not add to the DOM', () => { + alert = createAlert({ message: mockMessage }); + + expect(alert).toBeNull(); + expect(document.querySelector('.gl-alert')).toBeNull(); + }); + }); + + describe('with flash-container', () => { + beforeEach(() => { + setHTMLFixture('<div class="flash-container"></div>'); + }); + + afterEach(() => { + if (alert) { + alert.$destroy(); + } + document.querySelector('.flash-container')?.remove(); + }); + + it('adds alert element into the document by default', () => { + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.flash-container').textContent.trim()).toBe(mockMessage); + expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); + }); + + it('adds flash of a warning type', () => { + alert = createAlert({ message: mockMessage, variant: VARIANT_WARNING }); + + expect( + document.querySelector('.flash-container .gl-alert.gl-alert-warning'), + ).not.toBeNull(); + }); + + it('escapes text', () => { + alert = createAlert({ message: '<script>alert("a");</script>' }); + + const html = document.querySelector('.flash-container').innerHTML; + + expect(html).toContain('<script>alert("a");</script>'); + expect(html).not.toContain('<script>alert("a");</script>'); + }); + + it('adds alert into specified container', () => { + setHTMLFixture(` + <div class="my-alert-container"></div> + <div class="my-other-container"></div> + `); + + alert = createAlert({ message: mockMessage, containerSelector: '.my-alert-container' }); + + expect(document.querySelector('.my-alert-container .gl-alert')).not.toBeNull(); + expect(document.querySelector('.my-alert-container').innerText.trim()).toBe(mockMessage); + + expect(document.querySelector('.my-other-container .gl-alert')).toBeNull(); + expect(document.querySelector('.my-other-container').innerText.trim()).toBe(''); + }); + + it('adds alert into specified parent', () => { + setHTMLFixture(` + <div id="my-parent"> + <div class="flash-container"></div> + </div> + <div id="my-other-parent"> + <div class="flash-container"></div> + </div> + `); + + alert = createAlert({ message: mockMessage, parent: document.getElementById('my-parent') }); + + expect(document.querySelector('#my-parent .flash-container .gl-alert')).not.toBeNull(); + expect(document.querySelector('#my-parent .flash-container').innerText.trim()).toBe( + mockMessage, + ); + + expect(document.querySelector('#my-other-parent .flash-container .gl-alert')).toBeNull(); + expect(document.querySelector('#my-other-parent .flash-container').innerText.trim()).toBe( + '', + ); + }); + + it('removes element after clicking', () => { + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); + + document.querySelector('.gl-dismiss-btn').click(); + + expect(document.querySelector('.flash-container .gl-alert')).toBeNull(); + }); + + it('does not capture error using Sentry', () => { + alert = createAlert({ + message: mockMessage, + captureError: false, + error: new Error('Error!'), + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('captures error using Sentry', () => { + alert = createAlert({ + message: mockMessage, + captureError: true, + error: new Error('Error!'), + }); + + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error!', + }), + ); + }); + + describe('with buttons', () => { + const findAlertAction = () => document.querySelector('.flash-container .gl-alert-action'); + + it('adds primary button', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + }, + }); + + expect(findAlertAction().textContent.trim()).toBe('Ok'); + }); + + it('creates link with href', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + link: '/url', + text: 'Ok', + }, + }); + + const action = findAlertAction(); + + expect(action.textContent.trim()).toBe('Ok'); + expect(action.nodeName).toBe('A'); + expect(action.getAttribute('href')).toBe('/url'); + }); + + it('create button as href when no href is present', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + }, + }); + + const action = findAlertAction(); + + expect(action.nodeName).toBe('BUTTON'); + expect(action.getAttribute('href')).toBe(null); + }); + + it('escapes the title text', () => { + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: '<script>alert("a")</script>', + }, + }); + + const html = findAlertAction().innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); + }); + + it('calls actionConfig clickHandler on click', () => { + const clickHandler = jest.fn(); + + alert = createAlert({ + message: mockMessage, + primaryButton: { + text: 'Ok', + clickHandler, + }, + }); + + expect(clickHandler).toHaveBeenCalledTimes(0); + + findAlertAction().click(); + + expect(clickHandler).toHaveBeenCalledTimes(1); + expect(clickHandler).toHaveBeenCalledWith(expect.any(MouseEvent)); + }); + }); + + describe('Alert API', () => { + describe('dismiss', () => { + it('dismiss programmatically with .dismiss()', () => { + expect(document.querySelector('.gl-alert')).toBeNull(); + + alert = createAlert({ message: mockMessage }); + + expect(document.querySelector('.gl-alert')).not.toBeNull(); + + alert.dismiss(); + + expect(document.querySelector('.gl-alert')).toBeNull(); + }); + + it('calls onDismiss when dismissed', () => { + const dismissHandler = jest.fn(); + + alert = createAlert({ message: mockMessage, onDismiss: dismissHandler }); + + expect(dismissHandler).toHaveBeenCalledTimes(0); + + alert.dismiss(); + + expect(dismissHandler).toHaveBeenCalledTimes(1); + }); + }); + }); + }); + }); + describe('createFlash', () => { const message = 'test'; const fadeTransition = false; @@ -91,7 +324,7 @@ describe('Flash', () => { describe('with flash-container', () => { beforeEach(() => { - setFixtures( + setHTMLFixture( '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', ); }); @@ -115,11 +348,12 @@ describe('Flash', () => { }); it('escapes text', () => { - createFlash({ ...defaultParams, message: '<script>alert("a");</script>' }); + createFlash({ ...defaultParams, message: '<script>alert("a")</script>' }); - expect(document.querySelector('.flash-text').textContent.trim()).toBe( - '<script>alert("a");</script>', - ); + const html = document.querySelector('.flash-text').innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); }); it('adds flash into specified parent', () => { @@ -193,8 +427,10 @@ describe('Flash', () => { }, }); - expect(findFlashAction().href).toBe(`${window.location}testing`); - expect(findFlashAction().textContent.trim()).toBe('test'); + const action = findFlashAction(); + + expect(action.href).toBe(`${window.location}testing`); + expect(action.textContent.trim()).toBe('test'); }); it('uses hash as href when no href is present', () => { @@ -227,7 +463,10 @@ describe('Flash', () => { }, }); - expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>'); + const html = findFlashAction().innerHTML; + + expect(html).toContain('<script>alert("a")</script>'); + expect(html).not.toContain('<script>alert("a")</script>'); }); it('calls actionConfig clickHandler on click', () => { diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js index 570ac1e6ed1..92bc7596f7d 100644 --- a/spec/frontend/google_cloud/components/app_spec.js +++ b/spec/frontend/google_cloud/components/app_spec.js @@ -24,6 +24,8 @@ const HOME_PROPS = { serviceAccounts: [{}, {}], createServiceAccountUrl: '#url-create-service-account', emptyIllustrationUrl: '#url-empty-illustration', + deploymentsCloudRunUrl: '#url-deployments-cloud-run', + deploymentsCloudStorageUrl: '#deploymentsCloudStorageUrl', }; describe('google_cloud App component', () => { diff --git a/spec/frontend/google_cloud/components/deployments_service_table_spec.js b/spec/frontend/google_cloud/components/deployments_service_table_spec.js new file mode 100644 index 00000000000..76c3bfd00a8 --- /dev/null +++ b/spec/frontend/google_cloud/components/deployments_service_table_spec.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { GlButton, GlTable } from '@gitlab/ui'; +import DeploymentsServiceTable from '~/google_cloud/components/deployments_service_table.vue'; + +describe('google_cloud DeploymentsServiceTable component', () => { + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + const findButtons = () => findTable().findAllComponents(GlButton); + const findCloudRunButton = () => findButtons().at(0); + const findCloudStorageButton = () => findButtons().at(1); + + beforeEach(() => { + const propsData = { + cloudRunUrl: '#url-deployments-cloud-run', + cloudStorageUrl: '#url-deployments-cloud-storage', + }; + wrapper = mount(DeploymentsServiceTable, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should contain a table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('should contain configure cloud run button', () => { + const cloudRunButton = findCloudRunButton(); + expect(cloudRunButton.exists()).toBe(true); + expect(cloudRunButton.props().disabled).toBe(true); + }); + + it('should contain configure cloud storage button', () => { + const cloudStorageButton = findCloudStorageButton(); + expect(cloudStorageButton.exists()).toBe(true); + expect(cloudStorageButton.props().disabled).toBe(true); + }); +}); diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js index 9b4c3a79f11..3a009fc88ce 100644 --- a/spec/frontend/google_cloud/components/home_spec.js +++ b/spec/frontend/google_cloud/components/home_spec.js @@ -20,6 +20,8 @@ describe('google_cloud Home component', () => { serviceAccounts: [{}, {}], createServiceAccountUrl: '#url-create-service-account', emptyIllustrationUrl: '#url-empty-illustration', + deploymentsCloudRunUrl: '#url-deployments-cloud-run', + deploymentsCloudStorageUrl: '#deploymentsCloudStorageUrl', }; beforeEach(() => { @@ -42,7 +44,7 @@ describe('google_cloud Home component', () => { it('should contain three tab items', () => { expect(findTabItemsModel()).toEqual([ { title: 'Configuration', disabled: undefined }, - { title: 'Deployments', disabled: '' }, + { title: 'Deployments', disabled: undefined }, { title: 'Services', disabled: '' }, ]); }); diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js new file mode 100644 index 00000000000..ff38de28da6 --- /dev/null +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -0,0 +1,259 @@ +import { merge } from 'lodash'; +import { + trackFreeTrialAccountSubmissions, + trackNewRegistrations, + trackSaasTrialSubmit, + trackSaasTrialSkip, + trackSaasTrialGroup, + trackSaasTrialProject, + trackSaasTrialProjectImport, + trackSaasTrialGetStarted, +} from '~/google_tag_manager'; +import { setHTMLFixture } from 'helpers/fixtures'; +import { logError } from '~/lib/logger'; + +jest.mock('~/lib/logger'); + +describe('~/google_tag_manager/index', () => { + let spy; + + beforeEach(() => { + spy = jest.fn(); + + window.dataLayer = { + push: spy, + }; + window.gon.features = { + gitlabGtmDatalayer: true, + }; + }); + + const createHTML = ({ links = [], forms = [] } = {}) => { + // .foo elements are used to test elements which shouldn't do anything + const allLinks = links.concat({ cls: 'foo' }); + const allForms = forms.concat({ cls: 'foo' }); + + const el = document.createElement('div'); + + allLinks.forEach(({ cls = '', id = '', href = '#', text = 'Hello', attributes = {} }) => { + const a = document.createElement('a'); + a.id = id; + a.href = href || '#'; + a.className = cls; + a.textContent = text; + + Object.entries(attributes).forEach(([key, value]) => { + a.setAttribute(key, value); + }); + + el.append(a); + }); + + allForms.forEach(({ cls = '', id = '' }) => { + const form = document.createElement('form'); + form.id = id; + form.className = cls; + + el.append(form); + }); + + return el.innerHTML; + }; + + const triggerEvent = (selector, eventType) => { + const el = document.querySelector(selector); + + el.dispatchEvent(new Event(eventType)); + }; + + const getSelector = ({ id, cls }) => (id ? `#${id}` : `.${cls}`); + + const createTestCase = (subject, { forms = [], links = [] }) => { + const expectedFormEvents = forms.map(({ expectation, ...form }) => ({ + selector: getSelector(form), + trigger: 'submit', + expectation, + })); + + const expectedLinkEvents = links.map(({ expectation, ...link }) => ({ + selector: getSelector(link), + trigger: 'click', + expectation, + })); + + return [ + subject, + { + forms, + links, + expectedEvents: [...expectedFormEvents, ...expectedLinkEvents], + }, + ]; + }; + + const createOmniAuthTestCase = (subject, accountType) => + createTestCase(subject, { + forms: [ + { + id: 'new_new_user', + expectation: { + event: 'accountSubmit', + accountMethod: 'form', + accountType, + }, + }, + ], + links: [ + { + // id is needed so that the test selects the right element to trigger + id: 'test-0', + cls: 'js-oauth-login', + attributes: { + 'data-provider': 'myspace', + }, + expectation: { + event: 'accountSubmit', + accountMethod: 'myspace', + accountType, + }, + }, + { + id: 'test-1', + cls: 'js-oauth-login', + attributes: { + 'data-provider': 'gitlab', + }, + expectation: { + event: 'accountSubmit', + accountMethod: 'gitlab', + accountType, + }, + }, + ], + }); + + describe.each([ + createOmniAuthTestCase(trackFreeTrialAccountSubmissions, 'freeThirtyDayTrial'), + createOmniAuthTestCase(trackNewRegistrations, 'standardSignUp'), + createTestCase(trackSaasTrialSkip, { + links: [{ cls: 'js-skip-trial', expectation: { event: 'saasTrialSkip' } }], + }), + createTestCase(trackSaasTrialGroup, { + forms: [{ cls: 'js-saas-trial-group', expectation: { event: 'saasTrialGroup' } }], + }), + createTestCase(trackSaasTrialProject, { + forms: [{ id: 'new_project', expectation: { event: 'saasTrialProject' } }], + }), + createTestCase(trackSaasTrialProjectImport, { + links: [ + { + id: 'js-test-btn-0', + cls: 'js-import-project-btn', + attributes: { 'data-platform': 'bitbucket' }, + expectation: { event: 'saasTrialProjectImport', saasProjectImport: 'bitbucket' }, + }, + { + // id is neeeded so we trigger the right element in the test + id: 'js-test-btn-1', + cls: 'js-import-project-btn', + attributes: { 'data-platform': 'github' }, + expectation: { event: 'saasTrialProjectImport', saasProjectImport: 'github' }, + }, + ], + }), + createTestCase(trackSaasTrialGetStarted, { + links: [ + { + cls: 'js-get-started-btn', + expectation: { event: 'saasTrialGetStarted' }, + }, + ], + }), + ])('%p', (subject, { links = [], forms = [], expectedEvents }) => { + beforeEach(() => { + setHTMLFixture(createHTML({ links, forms })); + + subject(); + }); + + it.each(expectedEvents)('when %p', ({ selector, trigger, expectation }) => { + expect(spy).not.toHaveBeenCalled(); + + triggerEvent(selector, trigger); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expectation); + expect(logError).not.toHaveBeenCalled(); + }); + + it('when random link is clicked, does nothing', () => { + triggerEvent('a.foo', 'click'); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('when random form is submitted, does nothing', () => { + triggerEvent('form.foo', 'submit'); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('No listener events', () => { + it('when trackSaasTrialSubmit is invoked', () => { + expect(spy).not.toHaveBeenCalled(); + + trackSaasTrialSubmit(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ event: 'saasTrialSubmit' }); + expect(logError).not.toHaveBeenCalled(); + }); + }); + + describe.each([ + { dataLayer: null }, + { gon: { features: null } }, + { gon: { features: { gitlabGtmDatalayer: false } } }, + ])('when window %o', (windowAttrs) => { + beforeEach(() => { + merge(window, windowAttrs); + }); + + it('no ops', () => { + setHTMLFixture(createHTML({ forms: [{ id: 'new_project' }] })); + + trackSaasTrialProject(); + + triggerEvent('#new_project', 'submit'); + + expect(spy).not.toHaveBeenCalled(); + expect(logError).not.toHaveBeenCalled(); + }); + }); + + describe('when window.dataLayer throws error', () => { + const pushError = new Error('test'); + + beforeEach(() => { + window.dataLayer = { + push() { + throw pushError; + }, + }; + }); + + it('logs error', () => { + setHTMLFixture(createHTML({ forms: [{ id: 'new_project' }] })); + + trackSaasTrialProject(); + + triggerEvent('#new_project', 'submit'); + + expect(logError).toHaveBeenCalledWith( + 'Unexpected error while pushing to dataLayer', + pushError, + ); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 60d47895a95..8ea7e54aef4 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -100,6 +100,7 @@ describe('GroupItemComponent', () => { wrapper.destroy(); group.type = 'project'; + group.lastActivityAt = '2017-04-09T18:40:39.101Z'; wrapper = createComponent({ group }); expect(wrapper.vm.isGroup).toBe(false); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js index 49f3f5da43c..fdc267bc14a 100644 --- a/spec/frontend/groups/components/item_stats_spec.js +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -38,6 +38,7 @@ describe('ItemStats', () => { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4, + lastActivityAt: '2017-04-09T18:40:39.101Z', }; createComponent({ item }); diff --git a/spec/frontend/landing_spec.js b/spec/frontend/groups/landing_spec.js index 448d8ee2e81..f90f541eb96 100644 --- a/spec/frontend/landing_spec.js +++ b/spec/frontend/groups/landing_spec.js @@ -1,5 +1,5 @@ import Cookies from 'js-cookie'; -import Landing from '~/landing'; +import Landing from '~/groups/landing'; describe('Landing', () => { const test = {}; diff --git a/spec/frontend/transfer_edit_spec.js b/spec/frontend/groups/transfer_edit_spec.js index 4091d753fe5..bc070920d02 100644 --- a/spec/frontend/transfer_edit_spec.js +++ b/spec/frontend/groups/transfer_edit_spec.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { loadHTMLFixture } from 'helpers/fixtures'; -import setupTransferEdit from '~/transfer_edit'; +import setupTransferEdit from '~/groups/transfer_edit'; describe('setupTransferEdit', () => { const formSelector = '.js-group-transfer-form'; diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index faa70982fac..d1cf9f2e248 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -25,11 +25,12 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` <div class="gl-mr-3 gl-ml-2" > - <span - class="badge badge-pill" + <gl-badge-stub + size="md" + variant="muted" > - 4 - </span> + 4 + </gl-badge-stub> </div> <gl-icon-stub diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index 1768f01f3b8..b168eec0f16 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -73,6 +73,8 @@ describe('IDE clientside preview', () => { const createInitializedComponent = () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ sandpackReady: true, manager: { @@ -202,6 +204,8 @@ describe('IDE clientside preview', () => { it('returns false if loading and mainEntry exists', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); expect(wrapper.vm.showPreview).toBe(false); @@ -209,6 +213,8 @@ describe('IDE clientside preview', () => { it('returns true if not loading and mainEntry exists', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); expect(wrapper.vm.showPreview).toBe(true); @@ -218,12 +224,16 @@ describe('IDE clientside preview', () => { describe('showEmptyState', () => { it('returns true if no mainEntry exists', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); expect(wrapper.vm.showEmptyState).toBe(true); }); it('returns false if loading', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); expect(wrapper.vm.showEmptyState).toBe(false); @@ -231,6 +241,8 @@ describe('IDE clientside preview', () => { it('returns false if not loading and mainEntry exists', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); expect(wrapper.vm.showEmptyState).toBe(false); @@ -307,6 +319,8 @@ describe('IDE clientside preview', () => { describe('update', () => { it('initializes manager if manager is empty', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ sandpackReady: true }); wrapper.vm.update(); @@ -340,6 +354,8 @@ describe('IDE clientside preview', () => { describe('template', () => { it('renders ide-preview element when showPreview is true', () => { createComponent({ getters: { packageJson: dummyPackageJson } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); return wrapper.vm.$nextTick(() => { @@ -349,6 +365,8 @@ describe('IDE clientside preview', () => { it('renders empty state', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false }); return wrapper.vm.$nextTick(() => { @@ -360,6 +378,8 @@ describe('IDE clientside preview', () => { it('renders loading icon', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); return wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index c957c64aa10..15af2d03704 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -5,7 +5,6 @@ import Vue from 'vue'; import Vuex from 'vuex'; import '~/behaviors/markdown/render_gfm'; import waitForPromises from 'helpers/wait_for_promises'; -import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; @@ -540,7 +539,6 @@ describe('RepoEditor', () => { }, }); await vm.$nextTick(); - await vm.$nextTick(); expect(vm.initEditor).toHaveBeenCalled(); }); @@ -567,8 +565,8 @@ describe('RepoEditor', () => { // switching from edit to diff mode usually triggers editor initialization vm.$store.state.viewer = viewerTypes.diff; - // we delay returning the file to make sure editor doesn't initialize before we fetch file content - await waitUsingRealTimer(30); + jest.runOnlyPendingTimers(); + return 'rawFileData123\n'; }); @@ -598,8 +596,9 @@ describe('RepoEditor', () => { return aContent; }) .mockImplementationOnce(async () => { - // we delay returning fileB content to make sure the editor doesn't initialize prematurely - await waitUsingRealTimer(30); + // we delay returning fileB content + // to make sure the editor doesn't initialize prematurely + jest.advanceTimersByTime(30); return bContent; }); diff --git a/spec/frontend/ide/components/terminal/terminal_spec.js b/spec/frontend/ide/components/terminal/terminal_spec.js index c4b186c004a..afc49e22c83 100644 --- a/spec/frontend/ide/components/terminal/terminal_spec.js +++ b/spec/frontend/ide/components/terminal/terminal_spec.js @@ -128,6 +128,8 @@ describe('IDE Terminal', () => { canScrollDown: false, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ canScrollUp: true, canScrollDown: true }); return nextTick().then(() => { diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js index 9aa31136c89..3ede37e2eed 100644 --- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js @@ -188,6 +188,24 @@ describe('IDE pipelines actions', () => { .catch(done.fail); }); }); + + it('sets latest pipeline to `null` and stops polling on empty project', (done) => { + mockedState = { + ...mockedState, + rootGetters: { + lastCommit: null, + }, + }; + + testAction( + fetchLatestPipeline, + {}, + mockedState, + [{ type: types.RECEIVE_LASTEST_PIPELINE_SUCCESS, payload: null }], + [{ type: 'stopPipelinePolling' }], + done, + ); + }); }); describe('requestJobs', () => { diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index bf044e388ea..b0fb94d2b29 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -61,7 +61,7 @@ describe('DynamicField', () => { }); it(`renders GlFormCheckbox with correct text content when checkboxLabel is ${checkboxLabel}`, () => { - expect(findGlFormCheckbox().text()).toBe(checkboxLabel ?? defaultProps.title); + expect(findGlFormCheckbox().text()).toContain(checkboxLabel ?? defaultProps.title); }); it('does not render other types of input', () => { @@ -182,6 +182,17 @@ describe('DynamicField', () => { expect(findGlFormGroup().find('small').text()).toBe(defaultProps.help); }); + describe('when type is checkbox', () => { + it('renders description with help text', () => { + createComponent({ + type: 'checkbox', + }); + + expect(findGlFormGroup().find('small').exists()).toBe(false); + expect(findGlFormCheckbox().text()).toContain(defaultProps.help); + }); + }); + it('renders description with help text as HTML', () => { const helpHTML = 'The <strong>URL</strong> of the project'; diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 4c1394f3a87..8cf8a403e5d 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,9 +1,10 @@ +import { GlForm } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import * as Sentry from '@sentry/browser'; import { setHTMLFixture } from 'helpers/fixtures'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; @@ -13,7 +14,6 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; -import waitForPromises from 'helpers/wait_for_promises'; import { integrationLevels, I18N_SUCCESSFUL_CONNECTION_MESSAGE, @@ -23,9 +23,12 @@ import { import { createStore } from '~/integrations/edit/store'; import eventHub from '~/integrations/edit/event_hub'; import httpStatus from '~/lib/utils/http_status'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import { mockIntegrationProps } from '../mock_data'; jest.mock('~/integrations/edit/event_hub'); jest.mock('@sentry/browser'); +jest.mock('~/lib/utils/url_utility'); describe('IntegrationForm', () => { const mockToastShow = jest.fn(); @@ -34,12 +37,18 @@ describe('IntegrationForm', () => { let dispatch; let mockAxios; let mockForm; + let vueIntegrationFormFeatureFlag; + + const createForm = () => { + mockForm = document.createElement('form'); + jest.spyOn(document, 'querySelector').mockReturnValue(mockForm); + }; const createComponent = ({ customStateProps = {}, - featureFlags = {}, initialState = {}, props = {}, + mountFn = shallowMountExtended, } = {}) => { const store = createStore({ customState: { ...mockIntegrationProps, ...customStateProps }, @@ -47,11 +56,12 @@ describe('IntegrationForm', () => { }); dispatch = jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper = shallowMountExtended(IntegrationForm, { - propsData: { ...props, formSelector: '.test' }, - provide: { - glFeatures: featureFlags, - }, + if (!vueIntegrationFormFeatureFlag) { + createForm(); + } + + wrapper = mountFn(IntegrationForm, { + propsData: { ...props }, store, stubs: { OverrideDropdown, @@ -65,26 +75,33 @@ describe('IntegrationForm', () => { show: mockToastShow, }, }, + provide: { + glFeatures: { + vueIntegrationForm: vueIntegrationFormFeatureFlag, + }, + }, }); }; - const createForm = ({ isValid = true } = {}) => { - mockForm = document.createElement('form'); - jest.spyOn(document, 'querySelector').mockReturnValue(mockForm); - jest.spyOn(mockForm, 'checkValidity').mockReturnValue(isValid); - jest.spyOn(mockForm, 'submit'); - }; - const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown); const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); const findResetButton = () => wrapper.findByTestId('reset-button'); - const findSaveButton = () => wrapper.findByTestId('save-button'); + const findProjectSaveButton = () => wrapper.findByTestId('save-button'); + const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group'); const findTestButton = () => wrapper.findByTestId('test-button'); const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields); const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields); const findTriggerFields = () => wrapper.findComponent(TriggerFields); + const findGlForm = () => wrapper.findComponent(GlForm); + const findRedirectToField = () => wrapper.findByTestId('redirect-to-field'); + const findFormElement = () => (vueIntegrationFormFeatureFlag ? findGlForm().element : mockForm); + + const mockFormFunctions = ({ checkValidityReturn }) => { + jest.spyOn(findFormElement(), 'checkValidity').mockReturnValue(checkValidityReturn); + jest.spyOn(findFormElement(), 'submit'); + }; beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -220,6 +237,7 @@ describe('IntegrationForm', () => { createComponent({ customStateProps: { type: 'jira', testPath: '/test' }, + mountFn: mountExtended, }); }); @@ -338,6 +356,19 @@ describe('IntegrationForm', () => { }); }); }); + + describe('when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled', () => { + it('renders hidden fields', () => { + vueIntegrationFormFeatureFlag = true; + createComponent({ + customStateProps: { + redirectTo: '/services', + }, + }); + + expect(findRedirectToField().attributes('value')).toBe('/services'); + }); + }); }); describe('ActiveCheckbox', () => { @@ -358,190 +389,292 @@ describe('IntegrationForm', () => { }); describe.each` - formActive | novalidate - ${true} | ${null} - ${false} | ${'true'} + formActive | vueIntegrationFormEnabled | novalidate + ${true} | ${true} | ${null} + ${false} | ${true} | ${'novalidate'} + ${true} | ${false} | ${null} + ${false} | ${false} | ${'true'} `( - 'when `toggle-integration-active` is emitted with $formActive', - ({ formActive, novalidate }) => { + 'when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled and `toggle-integration-active` is emitted with $formActive', + ({ formActive, vueIntegrationFormEnabled, novalidate }) => { beforeEach(async () => { - createForm(); + vueIntegrationFormFeatureFlag = vueIntegrationFormEnabled; + createComponent({ customStateProps: { showActive: true, initialActivated: false, }, + mountFn: mountExtended, }); + mockFormFunctions({ checkValidityReturn: false }); await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive); }); it(`sets noValidate to ${novalidate}`, () => { - expect(mockForm.getAttribute('novalidate')).toBe(novalidate); + expect(findFormElement().getAttribute('novalidate')).toBe(novalidate); }); }, ); }); - describe('when `save` button is clicked', () => { - describe('buttons', () => { - beforeEach(async () => { - createForm(); - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: true, - }, + describe.each` + vueIntegrationFormEnabled + ${true} + ${false} + `( + 'when `vueIntegrationForm` feature flag is $vueIntegrationFormEnabled', + ({ vueIntegrationFormEnabled }) => { + beforeEach(() => { + vueIntegrationFormFeatureFlag = vueIntegrationFormEnabled; + }); + + describe('when `save` button is clicked', () => { + describe('buttons', () => { + beforeEach(async () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: true, + }, + mountFn: mountExtended, + }); + + await findProjectSaveButton().vm.$emit('click', new Event('click')); + }); + + it('sets save button `loading` prop to `true`', () => { + expect(findProjectSaveButton().props('loading')).toBe(true); + }); + + it('sets test button `disabled` prop to `true`', () => { + expect(findTestButton().props('disabled')).toBe(true); + }); }); - await findSaveButton().vm.$emit('click', new Event('click')); - }); + describe.each` + checkValidityReturn | integrationActive + ${true} | ${false} + ${true} | ${true} + ${false} | ${false} + `( + 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', + ({ integrationActive, checkValidityReturn }) => { + beforeEach(async () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: integrationActive, + }, + mountFn: mountExtended, + }); + + mockFormFunctions({ checkValidityReturn }); + + await findProjectSaveButton().vm.$emit('click', new Event('click')); + }); + + it('submits form', () => { + expect(findFormElement().submit).toHaveBeenCalledTimes(1); + }); + }, + ); + + describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => { + beforeEach(async () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + initialActivated: true, + }, + mountFn: mountExtended, + }); + mockFormFunctions({ checkValidityReturn: false }); + + await findProjectSaveButton().vm.$emit('click', new Event('click')); + }); - it('sets save button `loading` prop to `true`', () => { - expect(findSaveButton().props('loading')).toBe(true); - }); + it('does not submit form', () => { + expect(findFormElement().submit).not.toHaveBeenCalled(); + }); - it('sets test button `disabled` prop to `true`', () => { - expect(findTestButton().props('disabled')).toBe(true); - }); - }); + it('sets save button `loading` prop to `false`', () => { + expect(findProjectSaveButton().props('loading')).toBe(false); + }); - describe.each` - checkValidityReturn | integrationActive - ${true} | ${false} - ${true} | ${true} - ${false} | ${false} - `( - 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', - ({ integrationActive, checkValidityReturn }) => { - beforeEach(async () => { - createForm({ isValid: checkValidityReturn }); - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: integrationActive, - }, + it('sets test button `disabled` prop to `false`', () => { + expect(findTestButton().props('disabled')).toBe(false); }); - await findSaveButton().vm.$emit('click', new Event('click')); + it('emits `VALIDATE_INTEGRATION_FORM_EVENT`', () => { + expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + }); }); + }); - it('submit form', () => { - expect(mockForm.submit).toHaveBeenCalledTimes(1); - }); - }, - ); + describe('when `test` button is clicked', () => { + describe('when form is invalid', () => { + it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + }, + mountFn: mountExtended, + }); + mockFormFunctions({ checkValidityReturn: false }); - describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => { - beforeEach(async () => { - createForm({ isValid: false }); - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: true, - }, + findTestButton().vm.$emit('click', new Event('click')); + + expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + }); }); - await findSaveButton().vm.$emit('click', new Event('click')); - }); + describe('when form is valid', () => { + const mockTestPath = '/test'; - it('does not submit form', () => { - expect(mockForm.submit).not.toHaveBeenCalled(); - }); + beforeEach(() => { + createComponent({ + customStateProps: { + showActive: true, + canTest: true, + testPath: mockTestPath, + }, + mountFn: mountExtended, + }); + mockFormFunctions({ checkValidityReturn: true }); + }); - it('sets save button `loading` prop to `false`', () => { - expect(findSaveButton().props('loading')).toBe(false); - }); + describe('buttons', () => { + beforeEach(async () => { + await findTestButton().vm.$emit('click', new Event('click')); + }); - it('sets test button `disabled` prop to `false`', () => { - expect(findTestButton().props('disabled')).toBe(false); - }); + it('sets test button `loading` prop to `true`', () => { + expect(findTestButton().props('loading')).toBe(true); + }); - it('emits `VALIDATE_INTEGRATION_FORM_EVENT`', () => { - expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + it('sets save button `disabled` prop to `true`', () => { + expect(findProjectSaveButton().props('disabled')).toBe(true); + }); + }); + + describe.each` + scenario | replyStatus | errorMessage | expectToast | expectSentry + ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} + ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${'an error'} | ${false} + ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} + `('$scenario', ({ replyStatus, errorMessage, expectToast, expectSentry }) => { + beforeEach(async () => { + mockAxios.onPut(mockTestPath).replyOnce(replyStatus, { + error: Boolean(errorMessage), + message: errorMessage, + }); + + await findTestButton().vm.$emit('click', new Event('click')); + await waitForPromises(); + }); + + it(`calls toast with '${expectToast}'`, () => { + expect(mockToastShow).toHaveBeenCalledWith(expectToast); + }); + + it('sets `loading` prop of test button to `false`', () => { + expect(findTestButton().props('loading')).toBe(false); + }); + + it('sets save button `disabled` prop to `false`', () => { + expect(findProjectSaveButton().props('disabled')).toBe(false); + }); + + it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { + expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); + }); + }); + }); }); - }); - }); + }, + ); + + describe('when `reset-confirmation-modal` emits `reset` event', () => { + const mockResetPath = '/reset'; - describe('when `test` button is clicked', () => { - describe('when form is invalid', () => { - it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => { - createForm({ isValid: false }); + describe('buttons', () => { + beforeEach(async () => { createComponent({ customStateProps: { - showActive: true, + integrationLevel: integrationLevels.GROUP, canTest: true, + resetPath: mockResetPath, }, }); - findTestButton().vm.$emit('click', new Event('click')); + await findResetConfirmationModal().vm.$emit('reset'); + }); - expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT); + it('sets reset button `loading` prop to `true`', () => { + expect(findResetButton().props('loading')).toBe(true); }); - }); - describe('when form is valid', () => { - const mockTestPath = '/test'; + it('sets other button `disabled` props to `true`', () => { + expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(true); + expect(findTestButton().props('disabled')).toBe(true); + }); + }); - beforeEach(() => { - createForm({ isValid: true }); + describe('when "reset settings" request fails', () => { + beforeEach(async () => { + mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); createComponent({ customStateProps: { - showActive: true, + integrationLevel: integrationLevels.GROUP, canTest: true, - testPath: mockTestPath, + resetPath: mockResetPath, }, }); - }); - - describe('buttons', () => { - beforeEach(async () => { - await findTestButton().vm.$emit('click', new Event('click')); - }); - it('sets test button `loading` prop to `true`', () => { - expect(findTestButton().props('loading')).toBe(true); - }); + await findResetConfirmationModal().vm.$emit('reset'); + await waitForPromises(); + }); - it('sets save button `disabled` prop to `true`', () => { - expect(findSaveButton().props('disabled')).toBe(true); - }); + it('displays a toast', () => { + expect(mockToastShow).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE); }); - describe.each` - scenario | replyStatus | errorMessage | expectToast | expectSentry - ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true} - ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${'an error'} | ${false} - ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false} - `('$scenario', ({ replyStatus, errorMessage, expectToast, expectSentry }) => { - beforeEach(async () => { - mockAxios.onPut(mockTestPath).replyOnce(replyStatus, { - error: Boolean(errorMessage), - message: errorMessage, - }); + it('captures exception in Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + }); - await findTestButton().vm.$emit('click', new Event('click')); - await waitForPromises(); - }); + it('sets reset button `loading` prop to `false`', () => { + expect(findResetButton().props('loading')).toBe(false); + }); - it(`calls toast with '${expectToast}'`, () => { - expect(mockToastShow).toHaveBeenCalledWith(expectToast); - }); + it('sets button `disabled` props to `false`', () => { + expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(false); + expect(findTestButton().props('disabled')).toBe(false); + }); + }); - it('sets `loading` prop of test button to `false`', () => { - expect(findTestButton().props('loading')).toBe(false); + describe('when "reset settings" succeeds', () => { + beforeEach(async () => { + mockAxios.onPost(mockResetPath).replyOnce(httpStatus.OK); + createComponent({ + customStateProps: { + integrationLevel: integrationLevels.GROUP, + resetPath: mockResetPath, + }, }); - it('sets save button `disabled` prop to `false`', () => { - expect(findSaveButton().props('disabled')).toBe(false); - }); + await findResetConfirmationModal().vm.$emit('reset'); + await waitForPromises(); + }); - it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { - expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); - }); + it('calls `refreshCurrentPage`', () => { + expect(refreshCurrentPage).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js index b413de2b286..a5627d8b669 100644 --- a/spec/frontend/integrations/edit/store/actions_spec.js +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -4,17 +4,12 @@ import testAction from 'helpers/vuex_action_helper'; import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } from '~/integrations/constants'; import { setOverride, - setIsResetting, - requestResetIntegration, - receiveResetIntegrationSuccess, - receiveResetIntegrationError, requestJiraIssueTypes, receiveJiraIssueTypesSuccess, receiveJiraIssueTypesError, } from '~/integrations/edit/store/actions'; import * as types from '~/integrations/edit/store/mutation_types'; import createState from '~/integrations/edit/store/state'; -import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { mockJiraIssueTypes } from '../mock_data'; jest.mock('~/lib/utils/url_utility'); @@ -38,38 +33,6 @@ describe('Integration form store actions', () => { }); }); - describe('setIsResetting', () => { - it('should commit isResetting mutation', () => { - return testAction(setIsResetting, true, state, [ - { type: types.SET_IS_RESETTING, payload: true }, - ]); - }); - }); - - describe('requestResetIntegration', () => { - it('should commit REQUEST_RESET_INTEGRATION mutation', () => { - return testAction(requestResetIntegration, null, state, [ - { type: types.REQUEST_RESET_INTEGRATION }, - ]); - }); - }); - - describe('receiveResetIntegrationSuccess', () => { - it('should call refreshCurrentPage()', () => { - return testAction(receiveResetIntegrationSuccess, null, state, [], [], () => { - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - }); - }); - - describe('receiveResetIntegrationError', () => { - it('should commit RECEIVE_RESET_INTEGRATION_ERROR mutation', () => { - return testAction(receiveResetIntegrationError, null, state, [ - { type: types.RECEIVE_RESET_INTEGRATION_ERROR }, - ]); - }); - }); - describe('requestJiraIssueTypes', () => { describe.each` scenario | responseCode | response | action diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js index 641547550d1..ecac9d88982 100644 --- a/spec/frontend/integrations/edit/store/mutations_spec.js +++ b/spec/frontend/integrations/edit/store/mutations_spec.js @@ -17,30 +17,6 @@ describe('Integration form store mutations', () => { }); }); - describe(`${types.SET_IS_RESETTING}`, () => { - it('sets isResetting', () => { - mutations[types.SET_IS_RESETTING](state, true); - - expect(state.isResetting).toBe(true); - }); - }); - - describe(`${types.REQUEST_RESET_INTEGRATION}`, () => { - it('sets isResetting', () => { - mutations[types.REQUEST_RESET_INTEGRATION](state); - - expect(state.isResetting).toBe(true); - }); - }); - - describe(`${types.RECEIVE_RESET_INTEGRATION_ERROR}`, () => { - it('sets isResetting', () => { - mutations[types.RECEIVE_RESET_INTEGRATION_ERROR](state); - - expect(state.isResetting).toBe(false); - }); - }); - describe(`${types.SET_JIRA_ISSUE_TYPES}`, () => { it('sets jiraIssueTypes', () => { const jiraIssueTypes = ['issue', 'epic']; diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js index 5582be7fd3c..0b4ca8fb65c 100644 --- a/spec/frontend/integrations/edit/store/state_spec.js +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -5,8 +5,6 @@ describe('Integration form state factory', () => { expect(createState()).toEqual({ defaultState: null, customState: {}, - isSaving: false, - isResetting: false, override: false, isLoadingJiraIssueTypes: false, jiraIssueTypes: [], diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js index 8abd83887f7..6aa3e661677 100644 --- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -5,6 +5,8 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_PER_PAGE } from '~/api'; import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue'; +import IntegrationTabs from '~/integrations/overrides/components/integration_tabs.vue'; + import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; @@ -49,6 +51,7 @@ describe('IntegrationOverrides', () => { const findGlTable = () => wrapper.findComponent(GlTable); const findPagination = () => wrapper.findComponent(GlPagination); + const findIntegrationTabs = () => wrapper.findComponent(IntegrationTabs); const findRowsAsModel = () => findGlTable() .findAllComponents(GlLink) @@ -72,6 +75,12 @@ describe('IntegrationOverrides', () => { expect(table.exists()).toBe(true); expect(table.attributes('busy')).toBe('true'); }); + + it('renders IntegrationTabs with count as `null`', () => { + createComponent(); + + expect(findIntegrationTabs().props('projectOverridesCount')).toBe(null); + }); }); describe('when initial request is successful', () => { @@ -84,6 +93,13 @@ describe('IntegrationOverrides', () => { expect(table.attributes('busy')).toBeFalsy(); }); + it('renders IntegrationTabs with count', async () => { + createComponent(); + await waitForPromises(); + + expect(findIntegrationTabs().props('projectOverridesCount')).toBe(mockOverrides.length); + }); + describe('table template', () => { beforeEach(async () => { createComponent({ mountFn: mount }); diff --git a/spec/frontend/integrations/overrides/components/integration_tabs_spec.js b/spec/frontend/integrations/overrides/components/integration_tabs_spec.js new file mode 100644 index 00000000000..a728b4d391f --- /dev/null +++ b/spec/frontend/integrations/overrides/components/integration_tabs_spec.js @@ -0,0 +1,64 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlBadge, GlTab } from '@gitlab/ui'; + +import IntegrationTabs from '~/integrations/overrides/components/integration_tabs.vue'; +import { settingsTabTitle, overridesTabTitle } from '~/integrations/constants'; + +describe('IntegrationTabs', () => { + let wrapper; + + const editPath = 'mock/edit'; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(IntegrationTabs, { + propsData: props, + provide: { + editPath, + }, + stubs: { + GlTab, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findGlBadge = () => wrapper.findComponent(GlBadge); + const findGlTab = () => wrapper.findComponent(GlTab); + const findSettingsLink = () => wrapper.find('a'); + + describe('template', () => { + it('renders "Settings" tab as a link', () => { + createComponent({ mountFn: mount }); + + expect(findSettingsLink().text()).toMatchInterpolatedText(settingsTabTitle); + expect(findSettingsLink().attributes('href')).toBe(editPath); + }); + + it('renders "Projects using custom settings" tab as active', () => { + const projectOverridesCount = '1'; + + createComponent({ + props: { projectOverridesCount }, + }); + + expect(findGlTab().exists()).toBe(true); + expect(findGlTab().text()).toMatchInterpolatedText( + `${overridesTabTitle} ${projectOverridesCount}`, + ); + expect(findGlBadge().text()).toBe(projectOverridesCount); + }); + + describe('when count is `null', () => { + it('renders "Projects using custom settings" tab without count', () => { + createComponent(); + + expect(findGlTab().exists()).toBe(true); + expect(findGlTab().text()).toMatchInterpolatedText(overridesTabTitle); + expect(findGlBadge().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index e190ddf243e..3ab89b3dff2 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -474,6 +474,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user1] }); }); @@ -644,6 +646,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user3] }); }); @@ -712,6 +716,8 @@ describe('InviteMembersModal', () => { it('displays the invalid syntax error if one of the emails is invalid', async () => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user3, user4] }); mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID); @@ -787,6 +793,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteMembersToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsersToInvite: [user1, user3] }); mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); @@ -815,6 +823,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createComponent({ groupToBeSharedWith: sharedGroup }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ inviteeType: 'group' }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); @@ -837,6 +847,8 @@ describe('InviteMembersModal', () => { beforeEach(() => { createInviteGroupToGroupWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ groupToBeSharedWith: sharedGroup }); wrapper.vm.$toast = { show: jest.fn() }; diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index dd84b4fd78f..a3e426376d8 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -26,7 +26,7 @@ const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { const INVITATIONS_API_EMAIL_TAKEN = { message: { - 'email@example2.com': 'Invite email has already been taken', + 'email@example.org': 'Invite email has already been taken', }, status: 'error', }; diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index 6ac4c9e8546..6a896ccd21a 100644 --- a/spec/frontend/issuable/components/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -169,6 +169,8 @@ describe('RelatedIssuableItem', () => { }); it('renders disabled button when removeDisabled', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ removeDisabled: true }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index 9f07eea433a..fdc0bd7d72e 100644 --- a/spec/frontend/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import confidentialState from '~/confidential_merge_request/state'; -import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; +import CreateMergeRequestDropdown from '~/issues/create_merge_request_dropdown'; import axios from '~/lib/utils/axios_utils'; describe('CreateMergeRequestDropdown', () => { diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js index 7c5faeb8dc1..e9c48b60da4 100644 --- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js @@ -1,7 +1,7 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; -import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue'; +import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue'; describe('CE IssueCardTimeInfo component', () => { useFakeDate(2020, 11, 11); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index f24c090fa92..66428ee0492 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -5,8 +5,8 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; +import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; +import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; @@ -17,15 +17,15 @@ import { filteredTokens, locationSearch, urlParams, -} from 'jest/issues_list/mock_data'; +} from 'jest/issues/list/mock_data'; import createFlash, { FLASH_TYPES } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; -import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; -import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; +import IssuesListApp from '~/issues/list/components/issues_list_app.vue'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; import { CREATED_DESC, DUE_DATE_OVERDUE, @@ -41,9 +41,9 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, urlSortParams, -} from '~/issues_list/constants'; -import eventHub from '~/issues_list/eventhub'; -import { getSortOptions } from '~/issues_list/utils'; +} from '~/issues/list/constants'; +import eventHub from '~/issues/list/eventhub'; +import { getSortOptions } from '~/issues/list/utils'; import axios from '~/lib/utils/axios_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js index 633799816d8..d6d6bb14e9d 100644 --- a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js +++ b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js @@ -1,7 +1,7 @@ import { GlAlert, GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue'; +import JiraIssuesImportStatus from '~/issues/list/components/jira_issues_import_status_app.vue'; describe('JiraIssuesImportStatus', () => { const issuesPath = 'gitlab-org/gitlab-test/-/issues'; diff --git a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js index 1c9a87e8af2..0c52e66ff14 100644 --- a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js +++ b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js @@ -2,8 +2,8 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; -import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; +import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { emptySearchProjectsQueryResponse, diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues/list/mock_data.js index 948699876ce..948699876ce 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index 8e1d70db92d..0e4979fd7b4 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -7,14 +7,14 @@ import { locationSearchWithSpecialValues, urlParams, urlParamsWithSpecialValues, -} from 'jest/issues_list/mock_data'; +} from 'jest/issues/list/mock_data'; import { defaultPageSizeParams, DUE_DATE_VALUES, largePageSizeParams, RELATIVE_POSITION_ASC, urlSortParams, -} from '~/issues_list/constants'; +} from '~/issues/list/constants'; import { convertToApiParams, convertToSearchQuery, @@ -24,7 +24,7 @@ import { getInitialPageParams, getSortKey, getSortOptions, -} from '~/issues_list/utils'; +} from '~/issues/list/utils'; describe('getInitialPageParams', () => { it.each(Object.keys(urlSortParams))( diff --git a/spec/frontend/issues/new/components/title_suggestions_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js index 984d0c9d25b..f6b93cc5a62 100644 --- a/spec/frontend/issues/new/components/title_suggestions_spec.js +++ b/spec/frontend/issues/new/components/title_suggestions_spec.js @@ -38,6 +38,8 @@ describe('Issue title suggestions component', () => { }); it('renders component', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -47,6 +49,8 @@ describe('Issue title suggestions component', () => { it('does not render with empty search', () => { wrapper.setProps({ search: '' }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -55,6 +59,8 @@ describe('Issue title suggestions component', () => { }); it('does not render when loading', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ ...data, loading: 1, @@ -66,6 +72,8 @@ describe('Issue title suggestions component', () => { }); it('does not render with empty issues data', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ issues: [] }); return wrapper.vm.$nextTick(() => { @@ -74,6 +82,8 @@ describe('Issue title suggestions component', () => { }); it('renders list of issues', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -82,6 +92,8 @@ describe('Issue title suggestions component', () => { }); it('adds margin class to first item', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { @@ -90,6 +102,8 @@ describe('Issue title suggestions component', () => { }); it('does not add margin class to last item', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData(data); return wrapper.vm.$nextTick(() => { diff --git a/spec/frontend/issues/show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js index 3ece10e70db..7f7b16583e6 100644 --- a/spec/frontend/issues/show/components/fields/type_spec.js +++ b/spec/frontend/issues/show/components/fields/type_spec.js @@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IssueTypeField, { i18n } from '~/issues/show/components/fields/type.vue'; -import { IssuableTypes } from '~/issues/show/constants'; +import { issuableTypes } from '~/issues/show/constants'; import { getIssueStateQueryResponse, updateIssueStateQueryResponse, @@ -69,8 +69,8 @@ describe('Issue type field component', () => { it.each` at | text | icon - ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon} - ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon} + ${0} | ${issuableTypes[0].text} | ${issuableTypes[0].icon} + ${1} | ${issuableTypes[1].text} | ${issuableTypes[1].icon} `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => { expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon); expect(findTypeFromDropDownItemAt(at).text()).toBe(text); @@ -81,20 +81,20 @@ describe('Issue type field component', () => { }); it('renders a form select with the `issue_type` value', () => { - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); describe('with Apollo cache mock', () => { it('renders the selected issueType', async () => { mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); await waitForPromises(); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); it('updates the `issue_type` in the apollo cache when the value is changed', async () => { - findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident); + findTypeFromDropDownItems().at(1).vm.$emit('click', issuableTypes.incident); await wrapper.vm.$nextTick(); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.incident); }); describe('when user is a guest', () => { @@ -104,7 +104,7 @@ describe('Issue type field component', () => { expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(false); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.issue); }); it('and incident is selected, includes incident in the dropdown', async () => { @@ -113,7 +113,7 @@ describe('Issue type field component', () => { expect(findTypeFromDropDownItemAt(0).isVisible()).toBe(true); expect(findTypeFromDropDownItemAt(1).isVisible()).toBe(true); - expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident); + expect(findTypeFromDropDown().attributes('value')).toBe(issuableTypes.incident); }); }); }); diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index 2a16c699c4d..d09bf6faa13 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -4,11 +4,10 @@ import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; import createFlash, { FLASH_TYPES } from '~/flash'; -import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import { IssuableStatus, IssueType } from '~/issues/constants'; import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue'; -import { IssuableStatus } from '~/issues/constants'; -import { IssueStateEvent } from '~/issues/show/constants'; +import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql'; import * as urlUtility from '~/lib/utils/url_utility'; import eventHub from '~/notes/event_hub'; @@ -36,7 +35,7 @@ describe('HeaderActions component', () => { iid: '32', isIssueAuthor: true, issuePath: 'gitlab-org/gitlab-test/-/issues/1', - issueType: IssuableType.Issue, + issueType: IssueType.Issue, newIssuePath: 'gitlab-org/gitlab-test/-/issues/new', projectPath: 'gitlab-org/gitlab-test', reportAbusePath: @@ -112,14 +111,14 @@ describe('HeaderActions component', () => { describe.each` issueType - ${IssuableType.Issue} - ${IssuableType.Incident} + ${IssueType.Issue} + ${IssueType.Incident} `('when issue type is $issueType', ({ issueType }) => { describe('close/reopen button', () => { describe.each` description | issueState | buttonText | newIssueState - ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${IssueStateEvent.Close} - ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${IssueStateEvent.Reopen} + ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${ISSUE_STATE_EVENT_CLOSE} + ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${ISSUE_STATE_EVENT_REOPEN} `('$description', ({ issueState, buttonText, newIssueState }) => { beforeEach(() => { dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); @@ -306,7 +305,7 @@ describe('HeaderActions component', () => { input: { iid: defaultProps.iid, projectPath: defaultProps.projectPath, - stateEvent: IssueStateEvent.Close, + stateEvent: ISSUE_STATE_EVENT_CLOSE, }, }, }), @@ -345,7 +344,7 @@ describe('HeaderActions component', () => { input: { iid: defaultProps.iid.toString(), projectPath: defaultProps.projectPath, - stateEvent: IssueStateEvent.Close, + stateEvent: ISSUE_STATE_EVENT_CLOSE, }, }, }), diff --git a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js index 5a51ae3cfe0..b38d2b60057 100644 --- a/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js +++ b/spec/frontend/issues/show/components/sentry_error_stack_trace_spec.js @@ -1,11 +1,9 @@ +import Vue from 'vue'; import { GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; -import SentryErrorStackTrace from '~/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import SentryErrorStackTrace from '~/issues/show/components/sentry_error_stack_trace.vue'; describe('Sentry Error Stack Trace', () => { let actions; @@ -13,13 +11,14 @@ describe('Sentry Error Stack Trace', () => { let store; let wrapper; + Vue.use(Vuex); + function mountComponent({ stubs = { stacktrace: Stacktrace, }, } = {}) { wrapper = shallowMount(SentryErrorStackTrace, { - localVue, stubs, store, propsData: { diff --git a/spec/frontend/issues/show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js index 6d7a31a6c8c..68c2e3768c7 100644 --- a/spec/frontend/issues/show/issue_spec.js +++ b/spec/frontend/issues/show/issue_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { initIssuableApp } from '~/issues/show/issue'; +import { initIssueApp } from '~/issues/show'; import * as parseData from '~/issues/show/utils/parse_data'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; @@ -17,7 +17,7 @@ const setupHTML = (initialData) => { }; describe('Issue show index', () => { - describe('initIssuableApp', () => { + describe('initIssueApp', () => { it('should initialize app with no potential XSS attack', async () => { const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); @@ -29,7 +29,7 @@ describe('Issue show index', () => { const initialDataEl = document.getElementById('js-issuable-app'); const issuableData = parseData.parseIssuableData(initialDataEl); - initIssuableApp(issuableData, createStore()); + initIssueApp(issuableData, createStore()); await waitForPromises(); diff --git a/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap deleted file mode 100644 index c327b7de827..00000000000 --- a/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = ` -<gl-empty-state-stub - svgpath="/emptySvg" - title="There are no issues to show" -/> -`; - -exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`; - -exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`; - -exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`; diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js deleted file mode 100644 index f3c2ae1f9dc..00000000000 --- a/spec/frontend/issues_list/components/issuable_spec.js +++ /dev/null @@ -1,508 +0,0 @@ -import { GlSprintf, GlLabel, GlIcon, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import { trimText } from 'helpers/text_helper'; -import Issuable from '~/issues_list/components/issuable.vue'; -import { isScopedLabel } from '~/lib/utils/common_utils'; -import { formatDate } from '~/lib/utils/datetime_utility'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import initUserPopovers from '~/user_popovers'; -import IssueAssignees from '~/issuable/components/issue_assignees.vue'; -import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; - -jest.mock('~/user_popovers'); - -const TODAY = new Date(); - -const createTestDateFromDelta = (timeDelta) => - formatDate(new Date(TODAY.getTime() + timeDelta), 'yyyy-mm-dd'); - -// TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883 -const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31; -const TEST_MONTH_AGO = createTestDateFromDelta(-MONTHS_IN_MS); -const TEST_MONTH_LATER = createTestDateFromDelta(MONTHS_IN_MS); -const DATE_FORMAT = 'mmm d, yyyy'; -const TEST_USER_NAME = 'Tyler Durden'; -const TEST_BASE_URL = `${TEST_HOST}/issues`; -const TEST_TASK_STATUS = '50 of 100 tasks completed'; -const TEST_MILESTONE = { - title: 'Milestone title', - web_url: `${TEST_HOST}/milestone/1`, -}; -const TEXT_CLOSED = 'CLOSED'; -const TEST_META_COUNT = 100; -const MOCK_GITLAB_URL = 'http://0.0.0.0:3000'; - -describe('Issuable component', () => { - let issuable; - let wrapper; - - const factory = (props = {}, scopedLabelsAvailable = false) => { - wrapper = shallowMount(Issuable, { - propsData: { - issuable: simpleIssue, - baseUrl: TEST_BASE_URL, - ...props, - }, - provide: { - scopedLabelsAvailable, - }, - stubs: { - 'gl-sprintf': GlSprintf, - }, - }); - }; - - beforeEach(() => { - issuable = { ...simpleIssue }; - gon.gitlab_url = MOCK_GITLAB_URL; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const checkExists = (findFn) => () => findFn().exists(); - const hasIcon = (iconName, iconWrapper = wrapper) => - iconWrapper.findAll(GlIcon).wrappers.some((icon) => icon.props('name') === iconName); - const hasConfidentialIcon = () => hasIcon('eye-slash'); - const findTaskStatus = () => wrapper.find('.task-status'); - const findOpenedAgoContainer = () => wrapper.find('[data-testid="openedByMessage"]'); - const findAuthor = () => wrapper.find({ ref: 'openedAgoByContainer' }); - const findMilestone = () => wrapper.find('.js-milestone'); - const findMilestoneTooltip = () => findMilestone().attributes('title'); - const findDueDate = () => wrapper.find('.js-due-date'); - const findLabels = () => wrapper.findAll(GlLabel); - const findWeight = () => wrapper.find('[data-testid="weight"]'); - const findAssignees = () => wrapper.find(IssueAssignees); - const findBlockingIssuesCount = () => wrapper.find('[data-testid="blocking-issues"]'); - const findMergeRequestsCount = () => wrapper.find('[data-testid="merge-requests"]'); - const findUpvotes = () => wrapper.find('[data-testid="upvotes"]'); - const findDownvotes = () => wrapper.find('[data-testid="downvotes"]'); - const findNotes = () => wrapper.find('[data-testid="notes-count"]'); - const findBulkCheckbox = () => wrapper.find('input.selected-issuable'); - const findScopedLabels = () => findLabels().filter((w) => isScopedLabel({ title: w.text() })); - const findUnscopedLabels = () => findLabels().filter((w) => !isScopedLabel({ title: w.text() })); - const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]'); - const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]'); - const containsJiraLogo = () => wrapper.find('[data-testid="jira-logo"]').exists(); - const findHealthStatus = () => wrapper.find('.health-status'); - - describe('when mounted', () => { - it('initializes user popovers', () => { - expect(initUserPopovers).not.toHaveBeenCalled(); - - factory(); - - expect(initUserPopovers).toHaveBeenCalledWith([wrapper.vm.$refs.openedAgoByContainer.$el]); - }); - }); - - describe('when scopedLabels feature is available', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - - factory({ issuable }, true); - }); - - describe('when label is scoped', () => { - it('returns label with correct props', () => { - const scopedLabel = findScopedLabels().at(0); - - expect(scopedLabel.props('scoped')).toBe(true); - }); - }); - - describe('when label is not scoped', () => { - it('returns label with correct props', () => { - const notScopedLabel = findUnscopedLabels().at(0); - - expect(notScopedLabel.props('scoped')).toBe(false); - }); - }); - }); - - describe('when scopedLabels feature is not available', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - - factory({ issuable }); - }); - - describe('when label is scoped', () => { - it('label scoped props is false', () => { - const scopedLabel = findScopedLabels().at(0); - - expect(scopedLabel.props('scoped')).toBe(false); - }); - }); - - describe('when label is not scoped', () => { - it('label scoped props is false', () => { - const notScopedLabel = findUnscopedLabels().at(0); - - expect(notScopedLabel.props('scoped')).toBe(false); - }); - }); - }); - - describe('with simple issuable', () => { - beforeEach(() => { - Object.assign(issuable, { - has_tasks: false, - task_status: TEST_TASK_STATUS, - created_at: TEST_MONTH_AGO, - author: { - ...issuable.author, - name: TEST_USER_NAME, - }, - labels: [], - }); - - factory({ issuable }); - }); - - it.each` - desc | check - ${'bulk editing checkbox'} | ${checkExists(findBulkCheckbox)} - ${'confidential icon'} | ${hasConfidentialIcon} - ${'task status'} | ${checkExists(findTaskStatus)} - ${'milestone'} | ${checkExists(findMilestone)} - ${'due date'} | ${checkExists(findDueDate)} - ${'labels'} | ${checkExists(findLabels)} - ${'weight'} | ${checkExists(findWeight)} - ${'blocking issues count'} | ${checkExists(findBlockingIssuesCount)} - ${'merge request count'} | ${checkExists(findMergeRequestsCount)} - ${'upvotes'} | ${checkExists(findUpvotes)} - ${'downvotes'} | ${checkExists(findDownvotes)} - `('does not render $desc', ({ check }) => { - expect(check()).toBe(false); - }); - - it('show relative reference path', () => { - expect(wrapper.find('.js-ref-path').text()).toBe(issuable.references.relative); - }); - - it('does not have closed text', () => { - expect(wrapper.text()).not.toContain(TEXT_CLOSED); - }); - - it('does not have closed class', () => { - expect(wrapper.classes('closed')).toBe(false); - }); - - it('renders fuzzy created date and author', () => { - expect(trimText(findOpenedAgoContainer().text())).toContain( - `created 1 month ago by ${TEST_USER_NAME}`, - ); - }); - - it('renders no comments', () => { - expect(findNotes().classes('no-comments')).toBe(true); - }); - - it.each` - gitlabWebUrl | webUrl | expectedHref | expectedTarget | isExternal - ${undefined} | ${`${MOCK_GITLAB_URL}/issue`} | ${`${MOCK_GITLAB_URL}/issue`} | ${undefined} | ${false} - ${undefined} | ${'https://jira.com/issue'} | ${'https://jira.com/issue'} | ${'_blank'} | ${true} - ${'/gitlab-org/issue'} | ${'https://jira.com/issue'} | ${'/gitlab-org/issue'} | ${undefined} | ${false} - `( - 'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`', - async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget, isExternal }) => { - factory({ - issuable: { - ...issuable, - web_url: webUrl, - gitlab_web_url: gitlabWebUrl, - }, - }); - - const titleEl = findIssuableTitle(); - - expect(titleEl.exists()).toBe(true); - expect(titleEl.find(GlLink).attributes('href')).toBe(expectedHref); - expect(titleEl.find(GlLink).attributes('target')).toBe(expectedTarget); - expect(titleEl.find(GlLink).text()).toBe(issuable.title); - - expect(titleEl.find(GlIcon).exists()).toBe(isExternal); - }, - ); - }); - - describe('with confidential issuable', () => { - beforeEach(() => { - issuable.confidential = true; - - factory({ issuable }); - }); - - it('renders the confidential icon', () => { - expect(hasConfidentialIcon()).toBe(true); - }); - }); - - describe('with Jira issuable', () => { - beforeEach(() => { - issuable.external_tracker = 'jira'; - - factory({ issuable }); - }); - - it('renders the Jira icon', () => { - expect(containsJiraLogo()).toBe(true); - }); - - it('opens issuable in a new tab', () => { - expect(findIssuableTitle().props('target')).toBe('_blank'); - }); - - it('opens author in a new tab', () => { - expect(findAuthor().props('target')).toBe('_blank'); - }); - - describe('with Jira status', () => { - const expectedStatus = 'In Progress'; - - beforeEach(() => { - issuable.status = expectedStatus; - - factory({ issuable }); - }); - - it('renders the Jira status', () => { - expect(findIssuableStatus().text()).toBe(expectedStatus); - }); - }); - }); - - describe('with task status', () => { - beforeEach(() => { - Object.assign(issuable, { - has_tasks: true, - task_status: TEST_TASK_STATUS, - }); - - factory({ issuable }); - }); - - it('renders task status', () => { - expect(findTaskStatus().exists()).toBe(true); - expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS); - }); - }); - - describe.each` - desc | dueDate | expectedTooltipPart - ${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'} - ${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'} - `('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => { - beforeEach(() => { - issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate }; - - factory({ issuable }); - }); - - it('renders milestone', () => { - expect(findMilestone().exists()).toBe(true); - expect(hasIcon('clock', findMilestone())).toBe(true); - expect(findMilestone().text()).toEqual(TEST_MILESTONE.title); - }); - - it('renders tooltip', () => { - expect(findMilestoneTooltip()).toBe( - `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`, - ); - }); - - it('renders milestone with the correct href', () => { - const { title } = issuable.milestone; - const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL); - - expect(findMilestone().attributes('href')).toBe(expected); - }); - }); - - describe.each` - dueDate | hasClass | desc - ${TEST_MONTH_LATER} | ${false} | ${'with future due date'} - ${TEST_MONTH_AGO} | ${true} | ${'with past due date'} - `('$desc', ({ dueDate, hasClass }) => { - beforeEach(() => { - issuable.due_date = dueDate; - - factory({ issuable }); - }); - - it('renders due date', () => { - expect(findDueDate().exists()).toBe(true); - expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT)); - }); - - it(hasClass ? 'has cred class' : 'does not have cred class', () => { - expect(findDueDate().classes('cred')).toEqual(hasClass); - }); - }); - - describe('with labels', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - - factory({ issuable }); - }); - - it('renders labels', () => { - factory({ issuable }); - - const labels = findLabels().wrappers.map((label) => ({ - href: label.props('target'), - text: label.text(), - tooltip: label.attributes('description'), - })); - - const expected = testLabels.map((label) => ({ - href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL), - text: label.name, - tooltip: label.description, - })); - - expect(labels).toEqual(expected); - }); - }); - - describe('with labels for Jira issuable', () => { - beforeEach(() => { - issuable.labels = [...testLabels]; - issuable.external_tracker = 'jira'; - - factory({ issuable }); - }); - - it('renders labels', () => { - factory({ issuable }); - - const labels = findLabels().wrappers.map((label) => ({ - href: label.props('target'), - text: label.text(), - tooltip: label.attributes('description'), - })); - - const expected = testLabels.map((label) => ({ - href: mergeUrlParams({ 'labels[]': label.name }, TEST_BASE_URL), - text: label.name, - tooltip: label.description, - })); - - expect(labels).toEqual(expected); - }); - }); - - describe.each` - weight - ${0} - ${10} - ${12345} - `('with weight $weight', ({ weight }) => { - beforeEach(() => { - issuable.weight = weight; - - factory({ issuable }); - }); - - it('renders weight', () => { - expect(findWeight().exists()).toBe(true); - expect(findWeight().text()).toEqual(weight.toString()); - }); - }); - - describe('with closed state', () => { - beforeEach(() => { - issuable.state = 'closed'; - - factory({ issuable }); - }); - - it('renders closed text', () => { - expect(wrapper.text()).toContain(TEXT_CLOSED); - }); - - it('has closed class', () => { - expect(wrapper.classes('closed')).toBe(true); - }); - }); - - describe('with assignees', () => { - beforeEach(() => { - issuable.assignees = testAssignees; - - factory({ issuable }); - }); - - it('renders assignees', () => { - expect(findAssignees().exists()).toBe(true); - expect(findAssignees().props('assignees')).toEqual(testAssignees); - }); - }); - - describe.each` - desc | key | finder - ${'with blocking issues count'} | ${'blocking_issues_count'} | ${findBlockingIssuesCount} - ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount} - ${'with upvote count'} | ${'upvotes'} | ${findUpvotes} - ${'with downvote count'} | ${'downvotes'} | ${findDownvotes} - ${'with notes count'} | ${'user_notes_count'} | ${findNotes} - `('$desc', ({ key, finder }) => { - beforeEach(() => { - issuable[key] = TEST_META_COUNT; - - factory({ issuable }); - }); - - it('renders correct count', () => { - expect(finder().exists()).toBe(true); - expect(finder().text()).toBe(TEST_META_COUNT.toString()); - expect(finder().classes('no-comments')).toBe(false); - }); - }); - - describe('with bulk editing', () => { - describe.each` - selected | desc - ${true} | ${'when selected'} - ${false} | ${'when unselected'} - `('$desc', ({ selected }) => { - beforeEach(() => { - factory({ isBulkEditing: true, selected }); - }); - - it(`renders checked is ${selected}`, () => { - expect(findBulkCheckbox().element.checked).toBe(selected); - }); - - it('emits select when clicked', () => { - expect(wrapper.emitted().select).toBeUndefined(); - - findBulkCheckbox().trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]); - }); - }); - }); - }); - - if (IS_EE) { - describe('with health status', () => { - it('renders health status tag', () => { - factory({ issuable }); - expect(findHealthStatus().exists()).toBe(true); - }); - - it('does not render when health status is absent', () => { - issuable.health_status = null; - factory({ issuable }); - expect(findHealthStatus().exists()).toBe(false); - }); - }); - } -}); diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js deleted file mode 100644 index 11854db534e..00000000000 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ /dev/null @@ -1,653 +0,0 @@ -import { - GlEmptyState, - GlPagination, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, -} from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; -import Issuable from '~/issues_list/components/issuable.vue'; -import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue'; -import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants'; -import issuablesEventBus from '~/issues_list/eventhub'; -import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; - -jest.mock('~/flash'); -jest.mock('~/issues_list/eventhub'); -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - scrollToElement: () => {}, -})); - -const TEST_LOCATION = `${TEST_HOST}/issues`; -const TEST_ENDPOINT = '/issues'; -const TEST_CREATE_ISSUES_PATH = '/createIssue'; -const TEST_SVG_PATH = '/emptySvg'; - -const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL) - .fill(0) - .map((_, i) => ({ - id: i, - web_url: `url${i}`, - })); - -describe('Issuables list component', () => { - let mockAxios; - let wrapper; - let apiSpy; - - const setupApiMock = (cb) => { - apiSpy = jest.fn(cb); - - mockAxios.onGet(TEST_ENDPOINT).reply((cfg) => apiSpy(cfg)); - }; - - const factory = (props = { sortKey: 'priority' }) => { - const emptyStateMeta = { - createIssuePath: TEST_CREATE_ISSUES_PATH, - svgPath: TEST_SVG_PATH, - }; - - wrapper = shallowMount(IssuablesListApp, { - propsData: { - endpoint: TEST_ENDPOINT, - emptyStateMeta, - ...props, - }, - }); - }; - - const findLoading = () => wrapper.find(GlSkeletonLoading); - const findIssuables = () => wrapper.findAll(Issuable); - const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar); - const findFirstIssuable = () => findIssuables().wrappers[0]; - const findEmptyState = () => wrapper.find(GlEmptyState); - - beforeEach(() => { - mockAxios = new MockAdapter(axios); - - setWindowLocation(TEST_LOCATION); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - mockAxios.restore(); - }); - - describe('with failed issues response', () => { - beforeEach(() => { - setupApiMock(() => [500]); - - factory(); - - return waitForPromises(); - }); - - it('does not show loading', () => { - expect(wrapper.vm.loading).toBe(false); - }); - - it('flashes an error', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); - }); - - describe('with successful issues response', () => { - beforeEach(() => { - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-total': 100, - 'x-page': 2, - }, - ]); - }); - - it('has default props and data', () => { - factory(); - expect(wrapper.vm).toMatchObject({ - // Props - canBulkEdit: false, - emptyStateMeta: { - createIssuePath: TEST_CREATE_ISSUES_PATH, - svgPath: TEST_SVG_PATH, - }, - // Data - filters: { - state: 'opened', - }, - isBulkEditing: false, - issuables: [], - loading: true, - page: 1, - selection: {}, - totalItems: 0, - }); - }); - - it('does not call API until mounted', () => { - factory(); - expect(apiSpy).not.toHaveBeenCalled(); - }); - - describe('when mounted', () => { - beforeEach(() => { - factory(); - }); - - it('calls API', () => { - expect(apiSpy).toHaveBeenCalled(); - }); - - it('shows loading', () => { - expect(findLoading().exists()).toBe(true); - expect(findIssuables().length).toBe(0); - expect(findEmptyState().exists()).toBe(false); - }); - }); - - describe('when finished loading', () => { - beforeEach(() => { - factory(); - - return waitForPromises(); - }); - - it('does not display empty state', () => { - expect(wrapper.vm.issuables.length).toBeGreaterThan(0); - expect(wrapper.vm.emptyState).toEqual({}); - expect(wrapper.find(GlEmptyState).exists()).toBe(false); - }); - - it('sets the proper page and total items', () => { - expect(wrapper.vm.totalItems).toBe(100); - expect(wrapper.vm.page).toBe(2); - }); - - it('renders one page of issuables and pagination', () => { - expect(findIssuables().length).toBe(PAGE_SIZE); - expect(wrapper.find(GlPagination).exists()).toBe(true); - }); - }); - - it('does not render FilteredSearchBar', () => { - factory(); - - expect(findFilteredSearchBar().exists()).toBe(false); - }); - }); - - describe('with bulk editing enabled', () => { - beforeEach(() => { - issuablesEventBus.$on.mockReset(); - issuablesEventBus.$emit.mockReset(); - - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ canBulkEdit: true }); - - return waitForPromises(); - }); - - it('is not enabled by default', () => { - expect(wrapper.vm.isBulkEditing).toBe(false); - }); - - it('does not select issues by default', () => { - expect(wrapper.vm.selection).toEqual({}); - }); - - it('"Select All" checkbox toggles all visible issuables"', () => { - wrapper.vm.onSelectAll(); - expect(wrapper.vm.selection).toEqual( - wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), - ); - - wrapper.vm.onSelectAll(); - expect(wrapper.vm.selection).toEqual({}); - }); - - it('"Select All checkbox" selects all issuables if only some are selected"', () => { - wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true }; - wrapper.vm.onSelectAll(); - expect(wrapper.vm.selection).toEqual( - wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), - ); - }); - - it('selects and deselects issuables', () => { - const [i0, i1, i2] = wrapper.vm.issuables; - - expect(wrapper.vm.selection).toEqual({}); - wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); - expect(wrapper.vm.selection).toEqual({}); - wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true }); - wrapper.vm.onSelectIssuable({ issuable: i0, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true, 0: true }); - wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true, 0: true, 2: true }); - wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); - expect(wrapper.vm.selection).toEqual({ 1: true, 0: true, 2: true }); - wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); - expect(wrapper.vm.selection).toEqual({ 1: true, 2: true }); - }); - - it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => { - issuablesEventBus.$emit.mockReset(); - const i1 = wrapper.vm.issuables[1]; - - wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(1); - expect(issuablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); - }); - }); - - it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => { - issuablesEventBus.$emit.mockReset(); - - return wrapper.vm - .$nextTick() - .then(waitForPromises) - .then(() => { - const i1 = wrapper.vm.issuables[1]; - - wrapper.vm.onSelectIssuable({ issuable: i1, selected: false }); - }) - .then(wrapper.vm.$nextTick) - .then(() => { - expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(0); - }); - }); - - it('listens to a message to toggle bulk editing', () => { - expect(wrapper.vm.isBulkEditing).toBe(false); - expect(issuablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit'); - issuablesEventBus.$on.mock.calls[0][1](true); // Call the message handler - - return waitForPromises() - .then(() => { - expect(wrapper.vm.isBulkEditing).toBe(true); - issuablesEventBus.$on.mock.calls[0][1](false); - }) - .then(() => { - expect(wrapper.vm.isBulkEditing).toBe(false); - }); - }); - }); - - describe('with query params in window.location', () => { - const expectedFilters = { - assignee_username: 'root', - author_username: 'root', - confidential: 'yes', - my_reaction_emoji: 'airplane', - scope: 'all', - state: 'opened', - weight: '0', - milestone: 'v3.0', - labels: 'Aquapod,Astro', - order_by: 'milestone_due', - sort: 'desc', - }; - - describe('when page is not present in params', () => { - const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0¬[label_name][]=Afterpod¬[milestone_title][]=13'; - - beforeEach(() => { - setWindowLocation(query); - - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ sortKey: 'milestone_due_desc' }); - - return waitForPromises(); - }); - - afterEach(() => { - apiSpy.mockClear(); - }); - - it('applies filters and sorts', () => { - expect(wrapper.vm.hasFilters).toBe(true); - expect(wrapper.vm.filters).toEqual({ - ...expectedFilters, - 'not[milestone]': ['13'], - 'not[labels]': ['Afterpod'], - }); - - expect(apiSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - ...expectedFilters, - with_labels_details: true, - page: 1, - per_page: PAGE_SIZE, - 'not[milestone]': ['13'], - 'not[labels]': ['Afterpod'], - }, - }), - ); - }); - - it('passes the base url to issuable', () => { - expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION); - }); - }); - - describe('when page is present in the param', () => { - const query = - '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&weight=0&page=3'; - - beforeEach(() => { - setWindowLocation(query); - - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ sortKey: 'milestone_due_desc' }); - - return waitForPromises(); - }); - - afterEach(() => { - apiSpy.mockClear(); - }); - - it('applies filters and sorts', () => { - expect(apiSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - ...expectedFilters, - with_labels_details: true, - page: 3, - per_page: PAGE_SIZE, - }, - }), - ); - }); - }); - }); - - describe('with hash in window.location', () => { - beforeEach(() => { - setWindowLocation(`${TEST_LOCATION}#stuff`); - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory(); - return waitForPromises(); - }); - - it('passes the base url to issuable', () => { - expect(findFirstIssuable().props('baseUrl')).toBe(TEST_LOCATION); - }); - }); - - describe('with manual sort', () => { - beforeEach(() => { - setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); - factory({ sortKey: RELATIVE_POSITION }); - }); - - it('uses manual page size', () => { - expect(apiSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - per_page: PAGE_SIZE_MANUAL, - }), - }), - ); - }); - }); - - describe('with empty issues response', () => { - beforeEach(() => { - setupApiMock(() => [200, []]); - }); - - describe('with query in window location', () => { - beforeEach(() => { - setWindowLocation('?weight=Any'); - - factory(); - - return waitForPromises().then(() => wrapper.vm.$nextTick()); - }); - - it('should display "Sorry, your filter produced no results" if filters are too specific', () => { - expect(findEmptyState().props('title')).toMatchSnapshot(); - }); - }); - - describe('with closed state', () => { - beforeEach(() => { - setWindowLocation('?state=closed'); - - factory(); - - return waitForPromises().then(() => wrapper.vm.$nextTick()); - }); - - it('should display a message "There are no closed issues" if there are no closed issues', () => { - expect(findEmptyState().props('title')).toMatchSnapshot(); - }); - }); - - describe('with all state', () => { - beforeEach(() => { - setWindowLocation('?state=all'); - - factory(); - - return waitForPromises().then(() => wrapper.vm.$nextTick()); - }); - - it('should display a catch-all if there are no issues to show', () => { - expect(findEmptyState().element).toMatchSnapshot(); - }); - }); - - describe('with empty query', () => { - beforeEach(() => { - factory(); - - return wrapper.vm.$nextTick().then(waitForPromises); - }); - - it('should display the message "There are no open issues"', () => { - expect(findEmptyState().props('title')).toMatchSnapshot(); - }); - }); - }); - - describe('when paginates', () => { - const newPage = 3; - - describe('when total-items is defined in response headers', () => { - beforeEach(() => { - window.history.pushState = jest.fn(); - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-total': 100, - 'x-page': 2, - }, - ]); - - factory(); - - return waitForPromises(); - }); - - afterEach(() => { - // reset to original value - window.history.pushState.mockRestore(); - }); - - it('calls window.history.pushState one time', () => { - // Trigger pagination - wrapper.find(GlPagination).vm.$emit('input', newPage); - - expect(window.history.pushState).toHaveBeenCalledTimes(1); - }); - - it('sets params in the url', () => { - // Trigger pagination - wrapper.find(GlPagination).vm.$emit('input', newPage); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`, - ); - }); - }); - - describe('when total-items is not defined in the headers', () => { - const page = 2; - const prevPage = page - 1; - const nextPage = page + 1; - - beforeEach(() => { - setupApiMock(() => [ - 200, - MOCK_ISSUES.slice(0, PAGE_SIZE), - { - 'x-page': page, - }, - ]); - - factory(); - - return waitForPromises(); - }); - - it('finds the correct props applied to GlPagination', () => { - expect(wrapper.find(GlPagination).props()).toMatchObject({ - nextPage, - prevPage, - value: page, - }); - }); - }); - }); - - describe('when type is "jira"', () => { - it('renders FilteredSearchBar', () => { - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().exists()).toBe(true); - }); - - describe('initialSortBy', () => { - const query = '?sort=updated_asc'; - - it('sets default value', () => { - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialSortBy')).toBe('created_desc'); - }); - - it('sets value according to query', () => { - setWindowLocation(query); - - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialSortBy')).toBe('updated_asc'); - }); - }); - - describe('initialFilterValue', () => { - it('does not set value when no query', () => { - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([]); - }); - - it('sets value according to query', () => { - const query = '?search=free+text'; - - setWindowLocation(query); - - factory({ type: 'jira' }); - - expect(findFilteredSearchBar().props('initialFilterValue')).toEqual(['free text']); - }); - }); - - describe('on filter search', () => { - beforeEach(() => { - factory({ type: 'jira' }); - - window.history.pushState = jest.fn(); - }); - - afterEach(() => { - window.history.pushState.mockRestore(); - }); - - const emitOnFilter = (filter) => findFilteredSearchBar().vm.$emit('onFilter', filter); - - describe('empty filter', () => { - const mockFilter = []; - - it('updates URL with correct params', () => { - emitOnFilter(mockFilter); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened`, - ); - }); - }); - - describe('filter with search term', () => { - const mockFilter = [ - { - type: 'filtered-search-term', - value: { data: 'free' }, - }, - ]; - - it('updates URL with correct params', () => { - emitOnFilter(mockFilter); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&search=free`, - ); - }); - }); - - describe('filter with multiple search terms', () => { - const mockFilter = [ - { - type: 'filtered-search-term', - value: { data: 'free' }, - }, - { - type: 'filtered-search-term', - value: { data: 'text' }, - }, - ]; - - it('updates URL with correct params', () => { - emitOnFilter(mockFilter); - - expect(window.history.pushState).toHaveBeenCalledWith( - {}, - '', - `${TEST_LOCATION}?state=opened&search=free+text`, - ); - }); - }); - }); - }); -}); diff --git a/spec/frontend/issues_list/issuable_list_test_data.js b/spec/frontend/issues_list/issuable_list_test_data.js deleted file mode 100644 index 313aa15bd31..00000000000 --- a/spec/frontend/issues_list/issuable_list_test_data.js +++ /dev/null @@ -1,77 +0,0 @@ -export const simpleIssue = { - id: 442, - iid: 31, - title: 'Dismiss Cipher with no integrity', - state: 'opened', - created_at: '2019-08-26T19:06:32.667Z', - updated_at: '2019-08-28T19:53:58.314Z', - labels: [], - milestone: null, - assignees: [], - author: { - id: 3, - name: 'Elnora Bernhard', - username: 'treva.lesch', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/a8c0d9c2882406cf2a9b71494625a796?s=80&d=identicon', - web_url: 'http://localhost:3001/treva.lesch', - }, - assignee: null, - user_notes_count: 0, - blocking_issues_count: 0, - merge_requests_count: 0, - upvotes: 0, - downvotes: 0, - due_date: null, - confidential: false, - web_url: 'http://localhost:3001/h5bp/html5-boilerplate/issues/31', - has_tasks: false, - weight: null, - references: { - relative: 'html-boilerplate#45', - }, - health_status: 'on_track', -}; - -export const testLabels = [ - { - id: 1, - name: 'Tanuki', - description: 'A cute animal', - color: '#ff0000', - text_color: '#ffffff', - }, - { - id: 2, - name: 'Octocat', - description: 'A grotesque mish-mash of whiskers and tentacles', - color: '#333333', - text_color: '#000000', - }, - { - id: 3, - name: 'scoped::label', - description: 'A scoped label', - color: '#00ff00', - text_color: '#ffffff', - }, -]; - -export const testAssignees = [ - { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://localhost:3001/root', - }, - { - id: 22, - name: 'User 0', - username: 'user0', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon', - web_url: 'http://localhost:3001/user0', - }, -]; diff --git a/spec/frontend/issues_list/service_desk_helper_spec.js b/spec/frontend/issues_list/service_desk_helper_spec.js deleted file mode 100644 index 16aee853341..00000000000 --- a/spec/frontend/issues_list/service_desk_helper_spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import { emptyStateHelper, generateMessages } from '~/issues_list/service_desk_helper'; - -describe('service desk helper', () => { - const emptyStateMessages = generateMessages({}); - - // Note: isServiceDeskEnabled must not be true when isServiceDeskSupported is false (it's an invalid case). - describe.each` - isServiceDeskSupported | isServiceDeskEnabled | canEditProjectSettings | expectedMessage - ${true} | ${true} | ${true} | ${'serviceDeskEnabledAndCanEditProjectSettings'} - ${true} | ${true} | ${false} | ${'serviceDeskEnabledAndCannotEditProjectSettings'} - ${true} | ${false} | ${true} | ${'serviceDeskDisabledAndCanEditProjectSettings'} - ${true} | ${false} | ${false} | ${'serviceDeskDisabledAndCannotEditProjectSettings'} - ${false} | ${false} | ${true} | ${'serviceDeskIsNotSupported'} - ${false} | ${false} | ${false} | ${'serviceDeskIsNotEnabled'} - `( - 'isServiceDeskSupported = $isServiceDeskSupported, isServiceDeskEnabled = $isServiceDeskEnabled, canEditProjectSettings = $canEditProjectSettings', - ({ isServiceDeskSupported, isServiceDeskEnabled, canEditProjectSettings, expectedMessage }) => { - it(`displays ${expectedMessage} message`, () => { - const emptyStateMeta = { - isServiceDeskEnabled, - isServiceDeskSupported, - canEditProjectSettings, - }; - expect(emptyStateHelper(emptyStateMeta)).toEqual(emptyStateMessages[expectedMessage]); - }); - }, - ); -}); diff --git a/spec/frontend/jira_import/utils/jira_import_utils_spec.js b/spec/frontend/jira_import/utils/jira_import_utils_spec.js index 9696d95f8c4..4207038f50c 100644 --- a/spec/frontend/jira_import/utils/jira_import_utils_spec.js +++ b/spec/frontend/jira_import/utils/jira_import_utils_spec.js @@ -1,5 +1,5 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/jira_import/utils/constants'; import { calculateJiraImportLabel, extractJiraProjectsOptions, diff --git a/spec/frontend/jobs/bridge/app_spec.js b/spec/frontend/jobs/bridge/app_spec.js index 0e232ab240d..c0faab90552 100644 --- a/spec/frontend/jobs/bridge/app_spec.js +++ b/spec/frontend/jobs/bridge/app_spec.js @@ -1,27 +1,104 @@ -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getPipelineQuery from '~/jobs/bridge/graphql/queries/pipeline.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; import BridgeApp from '~/jobs/bridge/app.vue'; import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue'; import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; +import CiHeader from '~/vue_shared/components/header_ci_component.vue'; +import { + MOCK_BUILD_ID, + MOCK_PIPELINE_IID, + MOCK_PROJECT_FULL_PATH, + mockPipelineQueryResponse, +} from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Bridge Show Page', () => { let wrapper; + let mockApollo; + let mockPipelineQuery; + + const createComponent = (options) => { + wrapper = shallowMount(BridgeApp, { + provide: { + buildId: MOCK_BUILD_ID, + projectFullPath: MOCK_PROJECT_FULL_PATH, + pipelineIid: MOCK_PIPELINE_IID, + }, + mocks: { + $apollo: { + queries: { + pipeline: { + loading: true, + }, + }, + }, + }, + ...options, + }); + }; - const createComponent = () => { - wrapper = shallowMount(BridgeApp, {}); + const createComponentWithApollo = () => { + const handlers = [[getPipelineQuery, mockPipelineQuery]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + localVue, + apolloProvider: mockApollo, + mocks: {}, + }); }; + const findCiHeader = () => wrapper.findComponent(CiHeader); const findEmptyState = () => wrapper.findComponent(BridgeEmptyState); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findSidebar = () => wrapper.findComponent(BridgeSidebar); + beforeEach(() => { + mockPipelineQuery = jest.fn(); + }); + afterEach(() => { + mockPipelineQuery.mockReset(); wrapper.destroy(); }); - describe('template', () => { + describe('while pipeline query is loading', () => { beforeEach(() => { createComponent(); }); + it('renders loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('after pipeline query is loaded', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse); + createComponentWithApollo(); + waitForPromises(); + }); + + it('query is called with correct variables', async () => { + expect(mockPipelineQuery).toHaveBeenCalledTimes(1); + expect(mockPipelineQuery).toHaveBeenCalledWith({ + fullPath: MOCK_PROJECT_FULL_PATH, + iid: MOCK_PIPELINE_IID, + }); + }); + + it('renders CI header state', () => { + expect(findCiHeader().exists()).toBe(true); + }); + it('renders empty state', () => { expect(findEmptyState().exists()).toBe(true); }); @@ -30,4 +107,42 @@ describe('Bridge Show Page', () => { expect(findSidebar().exists()).toBe(true); }); }); + + describe('sidebar expansion', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse); + createComponentWithApollo(); + waitForPromises(); + }); + + describe('on resize', () => { + it.each` + breakpoint | isSidebarExpanded + ${'xs'} | ${false} + ${'sm'} | ${false} + ${'md'} | ${true} + ${'lg'} | ${true} + ${'xl'} | ${true} + `( + 'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"', + async ({ breakpoint, isSidebarExpanded }) => { + jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint); + + window.dispatchEvent(new Event('resize')); + await nextTick(); + + expect(findSidebar().exists()).toBe(isSidebarExpanded); + }, + ); + }); + + it('toggles expansion on button click', async () => { + expect(findSidebar().exists()).toBe(true); + + wrapper.vm.toggleSidebar(); + await nextTick(); + + expect(findSidebar().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/jobs/bridge/components/empty_state_spec.js b/spec/frontend/jobs/bridge/components/empty_state_spec.js index 83642450118..38c55b296f0 100644 --- a/spec/frontend/jobs/bridge/components/empty_state_spec.js +++ b/spec/frontend/jobs/bridge/components/empty_state_spec.js @@ -6,14 +6,13 @@ import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_d describe('Bridge Empty State', () => { let wrapper; - const createComponent = (props) => { + const createComponent = ({ downstreamPipelinePath }) => { wrapper = shallowMount(BridgeEmptyState, { provide: { emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH, }, propsData: { - downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM, - ...props, + downstreamPipelinePath, }, }); }; @@ -28,7 +27,7 @@ describe('Bridge Empty State', () => { describe('template', () => { beforeEach(() => { - createComponent(); + createComponent({ downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM }); }); it('renders illustration', () => { diff --git a/spec/frontend/jobs/bridge/components/sidebar_spec.js b/spec/frontend/jobs/bridge/components/sidebar_spec.js index ba4018753af..5006d4f08a6 100644 --- a/spec/frontend/jobs/bridge/components/sidebar_spec.js +++ b/spec/frontend/jobs/bridge/components/sidebar_spec.js @@ -1,24 +1,38 @@ import { GlButton, GlDropdown } from '@gitlab/ui'; -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue'; -import { BUILD_NAME } from '../mock_data'; +import CommitBlock from '~/jobs/components/commit_block.vue'; +import { mockCommit, mockJob } from '../mock_data'; describe('Bridge Sidebar', () => { let wrapper; - const createComponent = () => { + const MockHeaderEl = { + getBoundingClientRect() { + return { + bottom: '40', + }; + }, + }; + + const createComponent = ({ featureFlag } = {}) => { wrapper = shallowMount(BridgeSidebar, { provide: { - buildName: BUILD_NAME, + glFeatures: { + triggerJobRetryAction: featureFlag, + }, + }, + propsData: { + bridgeJob: mockJob, + commit: mockCommit, }, }); }; - const findSidebar = () => wrapper.find('aside'); + const findJobTitle = () => wrapper.find('h4'); + const findCommitBlock = () => wrapper.findComponent(CommitBlock); const findRetryDropdown = () => wrapper.find(GlDropdown); - const findToggle = () => wrapper.find(GlButton); + const findToggleBtn = () => wrapper.findComponent(GlButton); afterEach(() => { wrapper.destroy(); @@ -29,8 +43,23 @@ describe('Bridge Sidebar', () => { createComponent(); }); - it('renders retry dropdown', () => { - expect(findRetryDropdown().exists()).toBe(true); + it('renders job name', () => { + expect(findJobTitle().text()).toBe(mockJob.name); + }); + + it('renders commit information', () => { + expect(findCommitBlock().exists()).toBe(true); + }); + }); + + describe('styles', () => { + beforeEach(async () => { + jest.spyOn(document, 'querySelector').mockReturnValue(MockHeaderEl); + createComponent(); + }); + + it('calculates root styles correctly', () => { + expect(wrapper.attributes('style')).toBe('width: 290px; top: 40px;'); }); }); @@ -39,38 +68,32 @@ describe('Bridge Sidebar', () => { createComponent(); }); - it('toggles expansion on button click', async () => { - expect(findSidebar().classes()).not.toContain('gl-display-none'); + it('emits toggle sidebar event on button click', async () => { + expect(wrapper.emitted('toggleSidebar')).toBe(undefined); - findToggle().vm.$emit('click'); - await nextTick(); + findToggleBtn().vm.$emit('click'); - expect(findSidebar().classes()).toContain('gl-display-none'); + expect(wrapper.emitted('toggleSidebar')).toHaveLength(1); }); + }); - describe('on resize', () => { - it.each` - breakpoint | isSidebarExpanded - ${'xs'} | ${false} - ${'sm'} | ${false} - ${'md'} | ${true} - ${'lg'} | ${true} - ${'xl'} | ${true} - `( - 'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"', - async ({ breakpoint, isSidebarExpanded }) => { - jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint); - - window.dispatchEvent(new Event('resize')); - await nextTick(); - - if (isSidebarExpanded) { - expect(findSidebar().classes()).not.toContain('gl-display-none'); - } else { - expect(findSidebar().classes()).toContain('gl-display-none'); - } - }, - ); + describe('retry action', () => { + describe('when feature flag is ON', () => { + beforeEach(() => { + createComponent({ featureFlag: true }); + }); + + it('renders retry dropdown', () => { + expect(findRetryDropdown().exists()).toBe(true); + }); + }); + + describe('when feature flag is OFF', () => { + it('does not render retry dropdown', () => { + createComponent({ featureFlag: false }); + + expect(findRetryDropdown().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/jobs/bridge/mock_data.js b/spec/frontend/jobs/bridge/mock_data.js index 146d1a062ac..4084bb54163 100644 --- a/spec/frontend/jobs/bridge/mock_data.js +++ b/spec/frontend/jobs/bridge/mock_data.js @@ -1,3 +1,102 @@ export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg'; export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline'; -export const BUILD_NAME = 'Child Pipeline Trigger'; +export const MOCK_BUILD_ID = '1331'; +export const MOCK_PIPELINE_IID = '174'; +export const MOCK_PROJECT_FULL_PATH = '/root/project/'; +export const MOCK_SHA = '38f3d89147765427a7ce58be28cd76d14efa682a'; + +export const mockCommit = { + id: `gid://gitlab/CommitPresenter/${MOCK_SHA}`, + shortId: '38f3d891', + title: 'Update .gitlab-ci.yml file', + webPath: `/root/project/-/commit/${MOCK_SHA}`, + __typename: 'Commit', +}; + +export const mockJob = { + createdAt: '2021-12-10T09:05:45Z', + id: 'gid://gitlab/Ci::Build/1331', + name: 'triggerJobName', + scheduledAt: null, + startedAt: '2021-12-10T09:13:43Z', + status: 'SUCCESS', + triggered: null, + detailedStatus: { + id: '1', + detailsPath: '/root/project/-/jobs/1331', + icon: 'status_success', + group: 'success', + text: 'passed', + tooltip: 'passed', + __typename: 'DetailedStatus', + }, + downstreamPipeline: { + id: '1', + path: '/root/project/-/pipelines/175', + }, + stage: { + id: '1', + name: 'build', + __typename: 'CiStage', + }, + __typename: 'CiJob', +}; + +export const mockUser = { + id: 'gid://gitlab/User/1', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webPath: '/root', + webUrl: 'http://gdk.test:3000/root', + status: { + message: 'making great things', + __typename: 'UserStatus', + }, + __typename: 'UserCore', +}; + +export const mockStage = { + id: '1', + name: 'build', + jobs: { + nodes: [mockJob], + __typename: 'CiJobConnection', + }, + __typename: 'CiStage', +}; + +export const mockPipelineQueryResponse = { + data: { + project: { + id: '1', + pipeline: { + commit: mockCommit, + id: 'gid://gitlab/Ci::Pipeline/174', + iid: '88', + path: '/root/project/-/pipelines/174', + sha: MOCK_SHA, + ref: 'main', + refPath: 'path/to/ref', + user: mockUser, + detailedStatus: { + id: '1', + icon: 'status_failed', + group: 'failed', + __typename: 'DetailedStatus', + }, + stages: { + edges: [ + { + node: mockStage, + __typename: 'CiStageEdge', + }, + ], + __typename: 'CiStageConnection', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 482d0df4e9a..05988eecb10 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -114,6 +114,8 @@ describe('Job table app', () => { await wrapper.vm.$nextTick(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ jobs: { pageInfo: { diff --git a/spec/frontend/labels/delete_label_modal_spec.js b/spec/frontend/labels/delete_label_modal_spec.js index c1e6ce87990..98049538948 100644 --- a/spec/frontend/labels/delete_label_modal_spec.js +++ b/spec/frontend/labels/delete_label_modal_spec.js @@ -13,6 +13,10 @@ describe('DeleteLabelModal', () => { subjectName: 'GitLab Org', destroyPath: `${TEST_HOST}/2`, }, + { + labelName: 'admin label', + destroyPath: `${TEST_HOST}/3`, + }, ]; beforeEach(() => { @@ -22,8 +26,12 @@ describe('DeleteLabelModal', () => { const button = document.createElement('button'); button.setAttribute('class', 'js-delete-label-modal-button'); button.setAttribute('data-label-name', x.labelName); - button.setAttribute('data-subject-name', x.subjectName); button.setAttribute('data-destroy-path', x.destroyPath); + + if (x.subjectName) { + button.setAttribute('data-subject-name', x.subjectName); + } + button.innerHTML = 'Action'; buttonContainer.appendChild(button); }); @@ -62,6 +70,7 @@ describe('DeleteLabelModal', () => { index ${0} ${1} + ${2} `(`when multiple buttons exist`, ({ index }) => { beforeEach(() => { initDeleteLabelModal(); @@ -69,14 +78,22 @@ describe('DeleteLabelModal', () => { }); it('correct props are passed to gl-modal', () => { - expect(findModal().querySelector('.modal-title').innerHTML).toContain( - buttons[index].labelName, - ); - expect(findModal().querySelector('.modal-body').innerHTML).toContain( - buttons[index].subjectName, - ); + const button = buttons[index]; + + expect(findModal().querySelector('.modal-title').innerHTML).toContain(button.labelName); + + if (button.subjectName) { + expect(findModal().querySelector('.modal-body').textContent).toContain( + `${button.labelName} will be permanently deleted from ${button.subjectName}. This cannot be undone.`, + ); + } else { + expect(findModal().querySelector('.modal-body').textContent).toContain( + `${button.labelName} will be permanently deleted. This cannot be undone.`, + ); + } + expect(findModal().querySelector('.modal-footer .btn-danger').href).toContain( - buttons[index].destroyPath, + button.destroyPath, ); }); }); diff --git a/spec/frontend/lib/utils/resize_observer_spec.js b/spec/frontend/lib/utils/resize_observer_spec.js new file mode 100644 index 00000000000..419aff28935 --- /dev/null +++ b/spec/frontend/lib/utils/resize_observer_spec.js @@ -0,0 +1,68 @@ +import { contentTop } from '~/lib/utils/common_utils'; +import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; + +jest.mock('~/lib/utils/common_utils'); + +function mockStickyHeaderSize(val) { + contentTop.mockReturnValue(val); +} + +describe('ResizeObserver Utility', () => { + let observer; + const triggerResize = () => { + const entry = document.querySelector('#content-body'); + entry.dispatchEvent(new CustomEvent(`ResizeUpdate`, { detail: { entry } })); + }; + + beforeEach(() => { + mockStickyHeaderSize(90); + + jest.spyOn(document.documentElement, 'scrollTo'); + + setFixtures(`<div id="content-body"><div class="target">element to scroll to</div></div>`); + + const target = document.querySelector('.target'); + + jest.spyOn(target, 'getBoundingClientRect').mockReturnValue({ top: 200 }); + + observer = scrollToTargetOnResize({ + target: '.target', + container: '#content-body', + }); + }); + + afterEach(() => { + contentTop.mockReset(); + }); + + describe('Observer behavior', () => { + it('returns null for empty target', () => { + observer = scrollToTargetOnResize({ + target: '', + container: '#content-body', + }); + + expect(observer).toBe(null); + }); + + it('returns ResizeObserver instance', () => { + expect(observer).toBeInstanceOf(ResizeObserver); + }); + + it('scrolls body so anchor is just below sticky header (contentTop)', () => { + triggerResize(); + + expect(document.documentElement.scrollTo).toHaveBeenCalledWith({ top: 110 }); + }); + + const interactionEvents = ['mousedown', 'touchstart', 'keydown', 'wheel']; + it.each(interactionEvents)('does not hijack scroll after user input from %s', (eventType) => { + const event = new Event(eventType); + document.dispatchEvent(event); + + triggerResize(); + + expect(document.documentElement.scrollTo).not.toHaveBeenCalledWith(); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index aaa0a91ffe0..681fb05a6c4 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -128,7 +128,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` emptynodatasvgpath="/images/illustrations/monitoring/no_data.svg" emptyunabletoconnectsvgpath="/images/illustrations/monitoring/unable_to_connect.svg" selectedstate="gettingStarted" - settingspath="/monitoring/monitor-project/-/services/prometheus/edit" + settingspath="/monitoring/monitor-project/-/integrations/prometheus/edit" /> </div> `; diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 27f7489aa49..ff6f0b9b0c7 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -661,6 +661,8 @@ describe('Time series component', () => { const commitUrl = `${mockProjectDir}/-/commit/${mockSha}`; beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ tooltip: { type: 'deployments', diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 9331048bce3..7730e7f347f 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -792,6 +792,8 @@ describe('Dashboard', () => { }); createShallowWrapper({ hasMetrics: true }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hoveredPanel: panelRef }); return wrapper.vm.$nextTick(); diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index 589354e7849..f6d30384847 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -38,6 +38,8 @@ describe('DashboardsDropdown', () => { const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax const setSearchTerm = (searchTerm) => wrapper.setData({ searchTerm }); beforeEach(() => { diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js index 0c6e4211b10..36ad82e93a5 100644 --- a/spec/frontend/mr_popover/mr_popover_spec.js +++ b/spec/frontend/mr_popover/mr_popover_spec.js @@ -35,6 +35,8 @@ describe('MR Popover', () => { describe('loaded state', () => { it('matches the snapshot', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ mergeRequest: { title: 'Updated Title', @@ -55,6 +57,8 @@ describe('MR Popover', () => { }); it('does not show CI Icon if there is no pipeline data', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ mergeRequest: { state: 'opened', diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index c3a51c51de0..16dbf60cef4 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -263,6 +263,8 @@ describe('issue_comment_form component', () => { jest.spyOn(wrapper.vm, 'stopPolling'); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ note: 'hello world' }); await findCommentButton().trigger('click'); @@ -388,6 +390,8 @@ describe('issue_comment_form component', () => { it('should enable comment button if it has note', async () => { mountComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ note: 'Foo' }); expect(findCommentTypeDropdown().props('disabled')).toBe(false); diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 48bfd6eac5a..d3b5ab02f24 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -257,6 +257,8 @@ describe('issue_note_form component', () => { props = { ...props, ...options }; wrapper = createComponentWrapper(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isSubmittingWithKeydown: true }); const textarea = wrapper.find('textarea'); diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index 0782ec7cdd5..7a036d25559 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -88,6 +88,8 @@ describe('CustomNotificationsModal', () => { beforeEach(async () => { wrapper = createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ events: [ { id: 'new_release', enabled: true, name: 'New release', loading: false }, @@ -211,6 +213,8 @@ describe('CustomNotificationsModal', () => { wrapper = createComponent({ injectedProperties }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ events: [ { id: 'new_release', enabled: true, name: 'New release', loading: false }, @@ -239,6 +243,8 @@ describe('CustomNotificationsModal', () => { mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.NOT_FOUND, {}); wrapper = createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ events: [ { id: 'new_release', enabled: true, name: 'New release', loading: false }, diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js index f06300efa29..5278e730ec9 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -1,7 +1,6 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { GlDropdown } from 'jest/packages_and_registries/container_registry/explorer/stubs'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -51,6 +50,7 @@ describe('Details Header', () => { const findCleanup = () => findByTestId('cleanup'); const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); const findInfoIcon = () => wrapper.findComponent(GlIcon); + const findMenu = () => wrapper.findComponent(GlDropdown); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -139,51 +139,53 @@ describe('Details Header', () => { }); }); - describe('delete button', () => { - it('exists', () => { - mountComponent(); + describe('menu', () => { + it.each` + canDelete | disabled | isVisible + ${true} | ${false} | ${true} + ${true} | ${true} | ${false} + ${false} | ${false} | ${false} + ${false} | ${true} | ${false} + `( + 'when canDelete is $canDelete and disabled is $disabled is $isVisible that the menu is visible', + ({ canDelete, disabled, isVisible }) => { + mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); - expect(findDeleteButton().exists()).toBe(true); - }); + expect(findMenu().exists()).toBe(isVisible); + }, + ); - it('has the correct text', () => { - mountComponent(); + describe('delete button', () => { + it('exists', () => { + mountComponent(); - expect(findDeleteButton().text()).toBe('Delete image repository'); - }); + expect(findDeleteButton().exists()).toBe(true); + }); - it('has the correct props', () => { - mountComponent(); + it('has the correct text', () => { + mountComponent(); - expect(findDeleteButton().attributes()).toMatchObject( - expect.objectContaining({ - variant: 'danger', - }), - ); - }); + expect(findDeleteButton().text()).toBe('Delete image repository'); + }); - it('emits the correct event', () => { - mountComponent(); + it('has the correct props', () => { + mountComponent(); - findDeleteButton().vm.$emit('click'); + expect(findDeleteButton().attributes()).toMatchObject( + expect.objectContaining({ + variant: 'danger', + }), + ); + }); - expect(wrapper.emitted('delete')).toEqual([[]]); - }); + it('emits the correct event', () => { + mountComponent(); - it.each` - canDelete | disabled | isDisabled - ${true} | ${false} | ${undefined} - ${true} | ${true} | ${'true'} - ${false} | ${false} | ${'true'} - ${false} | ${true} | ${'true'} - `( - 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', - ({ canDelete, disabled, isDisabled }) => { - mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } }); + findDeleteButton().vm.$emit('click'); - expect(findDeleteButton().attributes('disabled')).toBe(isDisabled); - }, - ); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); }); describe('metadata items', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js deleted file mode 100644 index f14284e9efe..00000000000 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js +++ /dev/null @@ -1,54 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; -import { - NO_TAGS_TITLE, - NO_TAGS_MESSAGE, - MISSING_OR_DELETED_IMAGE_TITLE, - MISSING_OR_DELETED_IMAGE_MESSAGE, -} from '~/packages_and_registries/container_registry/explorer/constants'; - -describe('EmptyTagsState component', () => { - let wrapper; - - const findEmptyState = () => wrapper.find(GlEmptyState); - - const mountComponent = (propsData) => { - wrapper = shallowMount(component, { - stubs: { - GlEmptyState, - }, - propsData, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('contains gl-empty-state', () => { - mountComponent(); - expect(findEmptyState().exists()).toBe(true); - }); - - it.each` - isEmptyImage | title | description - ${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE} - ${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE} - `( - 'when isEmptyImage is $isEmptyImage has the correct props', - ({ isEmptyImage, title, description }) => { - mountComponent({ - noContainersImage: 'foo', - isEmptyImage, - }); - - expect(findEmptyState().props()).toMatchObject({ - title, - description, - svgPath: 'foo', - }); - }, - ); -}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js index 00b1d03b7c2..057312828ff 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js @@ -75,16 +75,19 @@ describe('tags list row', () => { }); it.each` - digest | disabled - ${'foo'} | ${true} - ${null} | ${false} - ${null} | ${true} - ${'foo'} | ${true} - `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => { - mountComponent({ tag: { ...tag, digest }, disabled }); + digest | disabled | isDisabled + ${'foo'} | ${true} | ${'true'} + ${null} | ${true} | ${'true'} + ${null} | ${false} | ${undefined} + ${'foo'} | ${false} | ${undefined} + `( + 'disabled attribute is set to $isDisabled when the digest $digest and disabled is $disabled', + ({ digest, disabled, isDisabled }) => { + mountComponent({ tag: { ...tag, digest }, disabled }); - expect(findCheckbox().attributes('disabled')).toBe('true'); - }); + expect(findCheckbox().attributes('disabled')).toBe(isDisabled); + }, + ); it('is wired to the selected prop', () => { mountComponent({ ...defaultProps, selected: true }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js index 56f12e2f0bb..0dcf988c814 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -1,16 +1,25 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlEmptyState } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { stripTypenames } from 'helpers/graphql_helpers'; -import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; + import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue'; import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql'; -import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index'; +import { + GRAPHQL_PAGE_SIZE, + NO_TAGS_TITLE, + NO_TAGS_MESSAGE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/container_registry/explorer/constants/index'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; const localVue = createLocalVue(); @@ -21,11 +30,20 @@ describe('Tags List', () => { let resolver; const tags = [...tagsMock]; + const defaultConfig = { + noContainersImage: 'noContainersImage', + }; + + const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findTagsListRow = () => wrapper.findAllComponents(TagsListRow); const findRegistryList = () => wrapper.findComponent(RegistryList); - const findEmptyState = () => wrapper.findComponent(EmptyTagsState); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findTagsLoader = () => wrapper.findComponent(TagsLoader); + const fireFirstSortUpdate = () => { + findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] }); + }; + const waitForApolloRequestRender = async () => { await waitForPromises(); await nextTick(); @@ -44,7 +62,7 @@ describe('Tags List', () => { stubs: { RegistryList }, provide() { return { - config: {}, + config: defaultConfig, }; }, }); @@ -61,10 +79,23 @@ describe('Tags List', () => { describe('registry list', () => { beforeEach(() => { mountComponent(); - + fireFirstSortUpdate(); return waitForApolloRequestRender(); }); + it('has a persisted search', () => { + expect(findPersistedSearch().props()).toMatchObject({ + defaultOrder: 'NAME', + defaultSort: 'asc', + sortableFields: [ + { + label: 'Name', + orderBy: 'NAME', + }, + ], + }); + }); + it('binds the correct props', () => { expect(findRegistryList().props()).toMatchObject({ title: '2 tags', @@ -75,11 +106,13 @@ describe('Tags List', () => { }); describe('events', () => { - it('prev-page fetch the previous page', () => { + it('prev-page fetch the previous page', async () => { findRegistryList().vm.$emit('prev-page'); expect(resolver).toHaveBeenCalledWith({ first: null, + name: '', + sort: 'NAME_ASC', before: tagsPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE, id: '1', @@ -92,6 +125,8 @@ describe('Tags List', () => { expect(resolver).toHaveBeenCalledWith({ after: tagsPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE, + name: '', + sort: 'NAME_ASC', id: '1', }); }); @@ -108,6 +143,7 @@ describe('Tags List', () => { describe('list rows', () => { it('one row exist for each tag', async () => { mountComponent(); + fireFirstSortUpdate(); await waitForApolloRequestRender(); @@ -116,6 +152,7 @@ describe('Tags List', () => { it('the correct props are bound to it', async () => { mountComponent({ propsData: { disabled: true, id: 1 } }); + fireFirstSortUpdate(); await waitForApolloRequestRender(); @@ -130,7 +167,7 @@ describe('Tags List', () => { describe('events', () => { it('select event update the selected items', async () => { mountComponent(); - + fireFirstSortUpdate(); await waitForApolloRequestRender(); findTagsListRow().at(0).vm.$emit('select'); @@ -142,7 +179,7 @@ describe('Tags List', () => { it('delete event emit a delete event', async () => { mountComponent(); - + fireFirstSortUpdate(); await waitForApolloRequestRender(); findTagsListRow().at(0).vm.$emit('delete'); @@ -154,32 +191,45 @@ describe('Tags List', () => { describe('when the list of tags is empty', () => { beforeEach(() => { resolver = jest.fn().mockResolvedValue(imageTagsMock([])); - }); - - it('has the empty state', async () => { mountComponent(); - - await waitForApolloRequestRender(); - - expect(findEmptyState().exists()).toBe(true); + fireFirstSortUpdate(); + return waitForApolloRequestRender(); }); - it('does not show the loader', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - + it('does not show the loader', () => { expect(findTagsLoader().exists()).toBe(false); }); - it('does not show the list', async () => { - mountComponent(); + it('does not show the list', () => { + expect(findRegistryList().exists()).toBe(false); + }); - await waitForApolloRequestRender(); + describe('empty state', () => { + it('default empty state', () => { + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultConfig.noContainersImage, + title: NO_TAGS_TITLE, + description: NO_TAGS_MESSAGE, + }); + }); - expect(findRegistryList().exists()).toBe(false); + it('when filtered shows a filtered message', async () => { + findPersistedSearch().vm.$emit('update', { + sort: 'NAME_ASC', + filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'foo' } }], + }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().props()).toMatchObject({ + svgPath: defaultConfig.noContainersImage, + title: NO_TAGS_MATCHING_FILTERS_TITLE, + description: NO_TAGS_MATCHING_FILTERS_DESCRIPTION, + }); + }); }); }); + describe('loading state', () => { it.each` isImageLoading | queryExecuting | loadingVisible @@ -191,7 +241,7 @@ describe('Tags List', () => { 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown', async ({ isImageLoading, queryExecuting, loadingVisible }) => { mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); - + fireFirstSortUpdate(); if (!queryExecuting) { await waitForApolloRequestRender(); } diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js index 9b821ba8ef3..7992bead60a 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -1,4 +1,4 @@ -import { GlKeysetPagination } from '@gitlab/ui'; +import { GlKeysetPagination, GlEmptyState } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import { nextTick } from 'vue'; @@ -8,7 +8,6 @@ import axios from '~/lib/utils/axios_utils'; import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; -import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; @@ -20,6 +19,8 @@ import { ALERT_DANGER_IMAGE, MISSING_OR_DELETED_IMAGE_BREADCRUMB, ROOT_IMAGE_TEXT, + MISSING_OR_DELETED_IMAGE_TITLE, + MISSING_OR_DELETED_IMAGE_MESSAGE, } from '~/packages_and_registries/container_registry/explorer/constants'; import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; @@ -50,7 +51,7 @@ describe('Details Page', () => { const findTagsList = () => wrapper.find(TagsList); const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDetailsHeader = () => wrapper.find(DetailsHeader); - const findEmptyState = () => wrapper.find(EmptyTagsState); + const findEmptyState = () => wrapper.find(GlEmptyState); const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); const findStatusAlert = () => wrapper.find(StatusAlert); const findDeleteImage = () => wrapper.find(DeleteImage); @@ -61,6 +62,10 @@ describe('Details Page', () => { updateName: jest.fn(), }; + const defaultConfig = { + noContainersImage: 'noContainersImage', + }; + const cleanTags = tagsMock.map((t) => { const result = { ...t }; // eslint-disable-next-line no-underscore-dangle @@ -78,7 +83,7 @@ describe('Details Page', () => { mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)), options, - config = {}, + config = defaultConfig, } = {}) => { localVue.use(VueApollo); @@ -154,7 +159,11 @@ describe('Details Page', () => { await waitForApolloRequestRender(); - expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props()).toMatchObject({ + description: MISSING_OR_DELETED_IMAGE_MESSAGE, + svgPath: defaultConfig.noContainersImage, + title: MISSING_OR_DELETED_IMAGE_TITLE, + }); }); }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap index 881d441e116..f95564e3fad 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap @@ -15,11 +15,14 @@ exports[`FileSha renders 1`] = ` foo <gl-button-stub - aria-label="Copy this value" + aria-label="Copy SHA" + aria-live="polite" buttontextclasses="" category="tertiary" + data-clipboard-handle-tooltip="false" data-clipboard-text="foo" icon="copy-to-clipboard" + id="clipboard-button-1" size="small" title="Copy SHA" variant="default" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js index 9ce590bfb51..d7caa8ca2d8 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js @@ -4,6 +4,8 @@ import FileSha from '~/packages_and_registries/infrastructure_registry/details/c import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('FileSha', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index 99a7b8e427a..7cdf21dde46 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -10,10 +10,10 @@ exports[`packages_list_app renders 1`] = ` <div> <section - class="row empty-state text-center" + class="gl-display-flex empty-state gl-text-center gl-flex-direction-column" > <div - class="col-12" + class="gl-max-w-full" > <div class="svg-250 svg-content" @@ -28,10 +28,10 @@ exports[`packages_list_app renders 1`] = ` </div> <div - class="col-12" + class="gl-max-w-full gl-m-auto" > <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="gl-mx-auto gl-my-0 gl-p-5" > <h1 class="gl-font-size-h-display gl-line-height-36 h4" diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js index 2fb76b98925..26569f20e94 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js @@ -134,6 +134,8 @@ describe('packages_list', () => { }); it('deleteItemConfirmation resets itemToBeDeleted', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted: 1 }); wrapper.vm.deleteItemConfirmation(); expect(wrapper.vm.itemToBeDeleted).toEqual(null); @@ -141,6 +143,8 @@ describe('packages_list', () => { it('deleteItemConfirmation emit package:delete', () => { const itemToBeDeleted = { id: 2 }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted }); wrapper.vm.deleteItemConfirmation(); return wrapper.vm.$nextTick(() => { @@ -149,6 +153,8 @@ describe('packages_list', () => { }); it('deleteItemCanceled resets itemToBeDeleted', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted: 1 }); wrapper.vm.deleteItemCanceled(); expect(wrapper.vm.itemToBeDeleted).toEqual(null); @@ -194,6 +200,8 @@ describe('packages_list', () => { beforeEach(() => { mountComponent(); eventSpy = jest.spyOn(Tracking, 'event'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap index e9f80d5f512..b3d0d88be4d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap @@ -23,14 +23,18 @@ exports[`ConanInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Conan Setup Command" - instruction="conan remote add gitlab conanPath" + instruction="conan remote add gitlab http://gdk.test:3000/api/v4/projects/1/packages/conan" label="Add Conan Remote" trackingaction="copy_conan_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the Conan registry, + <gl-link-stub + href="/help/user/packages/conan_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap index 881d441e116..f95564e3fad 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/file_sha_spec.js.snap @@ -15,11 +15,14 @@ exports[`FileSha renders 1`] = ` foo <gl-button-stub - aria-label="Copy this value" + aria-label="Copy SHA" + aria-live="polite" buttontextclasses="" category="tertiary" + data-clipboard-handle-tooltip="false" data-clipboard-text="foo" icon="copy-to-clipboard" + id="clipboard-button-1" size="small" title="Copy SHA" variant="default" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap index 4865b8205ab..67f1906f6fd 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/maven_installation_spec.js.snap @@ -19,7 +19,7 @@ exports[`MavenInstallation groovy renders all the messages 1`] = ` <code-instruction-stub copytext="Copy add Gradle Groovy DSL repository command" instruction="maven { - url 'mavenPath' + url 'http://gdk.test:3000/api/v4/projects/1/packages/maven' }" label="Add Gradle Groovy DSL repository command" multiline="true" @@ -47,7 +47,7 @@ exports[`MavenInstallation kotlin renders all the messages 1`] = ` <code-instruction-stub copytext="Copy add Gradle Kotlin DSL repository command" - instruction="maven(\\"mavenPath\\")" + instruction="maven(\\"http://gdk.test:3000/api/v4/projects/1/packages/maven\\")" label="Add Gradle Kotlin DSL repository command" multiline="true" trackingaction="copy_kotlin_add_to_source_command" @@ -64,9 +64,15 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` /> <p> - <gl-sprintf-stub - message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block." - /> + Copy and paste this inside your + <code> + pom.xml + </code> + + <code> + dependencies + </code> + block. </p> <code-instruction-stub @@ -97,9 +103,11 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` </h3> <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file." - /> + If you haven't already done so, you will need to add the below to your + <code> + pom.xml + </code> + file. </p> <code-instruction-stub @@ -107,19 +115,19 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` instruction="<repositories> <repository> <id>gitlab-maven</id> - <url>mavenPath</url> + <url>http://gdk.test:3000/api/v4/projects/1/packages/maven</url> </repository> </repositories> <distributionManagement> <repository> <id>gitlab-maven</id> - <url>mavenPath</url> + <url>http://gdk.test:3000/api/v4/projects/1/packages/maven</url> </repository> <snapshotRepository> <id>gitlab-maven</id> - <url>mavenPath</url> + <url>http://gdk.test:3000/api/v4/projects/1/packages/maven</url> </snapshotRepository> </distributionManagement>" label="" @@ -127,9 +135,13 @@ exports[`MavenInstallation maven renders all the messages 1`] = ` trackingaction="copy_maven_setup_xml" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the Maven registry, + <gl-link-stub + href="/help/user/packages/maven_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap index d5649e39561..4520ae9c328 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap @@ -32,14 +32,18 @@ exports[`NpmInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy npm setup command" - instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc" + instruction="echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc" label="" trackingaction="copy_npm_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." - /> + You may also need to setup authentication using an auth token. + <gl-link-stub + href="/help/user/packages/npm_registry/index" + target="_blank" + > + See the documentation + </gl-link-stub> + to find out more. </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap index 29ddd7b77ed..92930a6309a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap @@ -23,14 +23,18 @@ exports[`NugetInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy NuGet Setup Command" - instruction="nuget source Add -Name \\"GitLab\\" -Source \\"nugetPath\\" -UserName <your_username> -Password <your_token>" + instruction="nuget source Add -Name \\"GitLab\\" -Source \\"http://gdk.test:3000/api/v4/projects/1/packages/nuget/index.json\\" -UserName <your_username> -Password <your_token>" label="Add NuGet Source" trackingaction="copy_nuget_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the NuGet registry, + <gl-link-stub + href="/help/user/packages/nuget_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap index 158bbbc3463..06ae8645101 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -10,7 +10,7 @@ exports[`PypiInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Pip command" data-testid="pip-command" - instruction="pip install @gitlab-org/package-15 --extra-index-url pypiPath" + instruction="pip install @gitlab-org/package-15 --extra-index-url http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple" label="Pip Command" trackingaction="copy_pip_install_command" trackinglabel="code_instruction" @@ -23,16 +23,18 @@ exports[`PypiInstallation renders all the messages 1`] = ` </h3> <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file." - /> + If you haven't already done so, you will need to add the below to your + <code> + .pypirc + </code> + file. </p> <code-instruction-stub copytext="Copy .pypirc content" data-testid="pypi-setup-content" instruction="[gitlab] -repository = pypiSetupPath +repository = http://gdk.test:3000/api/v4/projects/1/packages/pypi username = __token__ password = <your personal access token>" label="" @@ -40,9 +42,13 @@ password = <your personal access token>" trackingaction="copy_pypi_setup_command" trackinglabel="code_instruction" /> - - <gl-sprintf-stub - message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." - /> + For more information on the PyPi registry, + <gl-link-stub + href="/help/user/packages/pypi_repository/index" + target="_blank" + > + see the documentation + </gl-link-stub> + . </div> `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js index aedf20e873a..0aba8f7efc7 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/composer_installation_spec.js @@ -7,6 +7,7 @@ import { TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, PACKAGE_TYPE_COMPOSER, + COMPOSER_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_COMPOSER }; @@ -24,9 +25,6 @@ describe('ComposerInstallation', () => { function createComponent(groupListUrl = 'groupListUrl') { wrapper = shallowMountExtended(ComposerInstallation, { provide: { - composerHelpPath: 'composerHelpPath', - composerConfigRepositoryName: 'composerConfigRepositoryName', - composerPath: 'composerPath', groupListUrl, }, propsData: { packageEntity }, @@ -61,7 +59,7 @@ describe('ComposerInstallation', () => { const registryIncludeCommand = findRegistryInclude(); expect(registryIncludeCommand.exists()).toBe(true); expect(registryIncludeCommand.props()).toMatchObject({ - instruction: `composer config repositories.composerConfigRepositoryName '{"type": "composer", "url": "composerPath"}'`, + instruction: `composer config repositories.${packageEntity.composerConfigRepositoryUrl} '{"type": "composer", "url": "${packageEntity.composerUrl}"}'`, copyText: 'Copy registry include', trackingAction: TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, }); @@ -96,7 +94,7 @@ describe('ComposerInstallation', () => { 'For more information on Composer packages in GitLab, see the documentation.', ); expect(findHelpLink().attributes()).toMatchObject({ - href: 'composerHelpPath', + href: COMPOSER_HELP_PATH, target: '_blank', }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js index 6b642cc21b7..bf9425def9a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/conan_installation_spec.js @@ -1,8 +1,12 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import ConanInstallation from '~/packages_and_registries/package_registry/components/details/conan_installation.vue'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; -import { PACKAGE_TYPE_CONAN } from '~/packages_and_registries/package_registry/constants'; +import { + PACKAGE_TYPE_CONAN, + CONAN_HELP_PATH, +} from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_CONAN }; @@ -12,16 +16,16 @@ describe('ConanInstallation', () => { const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent() { wrapper = shallowMountExtended(ConanInstallation, { - provide: { - conanHelpPath: 'conanHelpPath', - conanPath: 'conanPath', - }, propsData: { packageEntity, }, + stubs: { + GlSprintf, + }, }); } @@ -58,8 +62,15 @@ describe('ConanInstallation', () => { describe('setup commands', () => { it('renders the correct command', () => { expect(findCodeInstructions().at(1).props('instruction')).toBe( - 'conan remote add gitlab conanPath', + `conan remote add gitlab ${packageEntity.conanUrl}`, ); }); + + it('has a link to the docs', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: CONAN_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js index ebfbbe5b864..feed7a7c46c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/file_sha_spec.js @@ -4,6 +4,8 @@ import FileSha from '~/packages_and_registries/package_registry/components/detai import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('FileSha', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js index eed7e903833..fc60039db30 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/maven_installation_spec.js @@ -1,3 +1,4 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -16,6 +17,7 @@ import { TRACKING_ACTION_COPY_KOTLIN_INSTALL_COMMAND, TRACKING_ACTION_COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, PACKAGE_TYPE_MAVEN, + MAVEN_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -28,9 +30,6 @@ describe('MavenInstallation', () => { metadata: mavenMetadata(), }; - const mavenHelpPath = 'mavenHelpPath'; - const mavenPath = 'mavenPath'; - const xmlCodeBlock = `<dependency> <groupId>appGroup</groupId> <artifactId>appName</artifactId> @@ -40,43 +39,43 @@ describe('MavenInstallation', () => { const mavenSetupXml = `<repositories> <repository> <id>gitlab-maven</id> - <url>${mavenPath}</url> + <url>${packageEntity.mavenUrl}</url> </repository> </repositories> <distributionManagement> <repository> <id>gitlab-maven</id> - <url>${mavenPath}</url> + <url>${packageEntity.mavenUrl}</url> </repository> <snapshotRepository> <id>gitlab-maven</id> - <url>${mavenPath}</url> + <url>${packageEntity.mavenUrl}</url> </snapshotRepository> </distributionManagement>`; const gradleGroovyInstallCommandText = `implementation 'appGroup:appName:appVersion'`; const gradleGroovyAddSourceCommandText = `maven { - url '${mavenPath}' + url '${packageEntity.mavenUrl}' }`; const gradleKotlinInstallCommandText = `implementation("appGroup:appName:appVersion")`; - const gradleKotlinAddSourceCommandText = `maven("${mavenPath}")`; + const gradleKotlinAddSourceCommandText = `maven("${packageEntity.mavenUrl}")`; const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent({ data = {} } = {}) { wrapper = shallowMountExtended(MavenInstallation, { - provide: { - mavenHelpPath, - mavenPath, - }, propsData: { packageEntity, }, data() { return data; }, + stubs: { + GlSprintf, + }, }); } @@ -148,6 +147,13 @@ describe('MavenInstallation', () => { trackingAction: TRACKING_ACTION_COPY_MAVEN_SETUP, }); }); + + it('has a setup link', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: MAVEN_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js index b89410ede13..8c0e2d948ca 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js @@ -1,4 +1,4 @@ -import { GlFormRadioGroup } from '@gitlab/ui'; +import { GlLink, GlSprintf, GlFormRadioGroup } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -15,6 +15,7 @@ import { YARN_PACKAGE_MANAGER, PROJECT_PACKAGE_ENDPOINT_TYPE, INSTANCE_PACKAGE_ENDPOINT_TYPE, + NPM_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -29,13 +30,12 @@ describe('NpmInstallation', () => { const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); const findEndPointTypeSector = () => wrapper.findComponent(GlFormRadioGroup); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent({ data = {} } = {}) { wrapper = shallowMountExtended(NpmInstallation, { provide: { - npmHelpPath: 'npmHelpPath', - npmPath: 'npmPath', - npmProjectPath: 'npmProjectPath', + npmInstanceUrl: 'npmInstanceUrl', }, propsData: { packageEntity, @@ -43,6 +43,7 @@ describe('NpmInstallation', () => { data() { return data; }, + stubs: { GlSprintf }, }); } @@ -58,6 +59,13 @@ describe('NpmInstallation', () => { expect(wrapper.element).toMatchSnapshot(); }); + it('has a setup link', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: NPM_HELP_PATH, + target: '_blank', + }); + }); + describe('endpoint type selector', () => { it('has the endpoint type selector', () => { expect(findEndPointTypeSector().exists()).toBe(true); @@ -109,7 +117,7 @@ describe('NpmInstallation', () => { it('renders the correct setup command', () => { expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo @gitlab-org:registry=npmPath/ >> .npmrc', + instruction: 'echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc', multiline: false, trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); @@ -121,7 +129,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: `echo @gitlab-org:registry=npmProjectPath/ >> .npmrc`, + instruction: `echo @gitlab-org:registry=${packageEntity.npmUrl}/ >> .npmrc`, multiline: false, trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); @@ -131,7 +139,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: `echo @gitlab-org:registry=npmPath/ >> .npmrc`, + instruction: `echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc`, multiline: false, trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); @@ -153,7 +161,7 @@ describe('NpmInstallation', () => { it('renders the correct registry command', () => { expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmInstanceUrl/\\" >> .yarnrc', multiline: false, trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); @@ -165,7 +173,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: `echo \\"@gitlab-org:registry\\" \\"npmProjectPath/\\" >> .yarnrc`, + instruction: `echo \\"@gitlab-org:registry\\" \\"${packageEntity.npmUrl}/\\" >> .yarnrc`, multiline: false, trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); @@ -175,7 +183,7 @@ describe('NpmInstallation', () => { await nextTick(); expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmInstanceUrl/\\" >> .yarnrc', multiline: false, trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js index c48a3f07299..d324d43258c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/nuget_installation_spec.js @@ -1,3 +1,4 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; @@ -6,6 +7,7 @@ import { TRACKING_ACTION_COPY_NUGET_INSTALL_COMMAND, TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, PACKAGE_TYPE_NUGET, + NUGET_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -15,21 +17,18 @@ describe('NugetInstallation', () => { let wrapper; const nugetInstallationCommandStr = 'nuget install @gitlab-org/package-15 -Source "GitLab"'; - const nugetSetupCommandStr = - 'nuget source Add -Name "GitLab" -Source "nugetPath" -UserName <your_username> -Password <your_token>'; + const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${packageEntity.nugetUrl}" -UserName <your_username> -Password <your_token>`; const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent() { wrapper = shallowMountExtended(NugetInstallation, { - provide: { - nugetHelpPath: 'nugetHelpPath', - nugetPath: 'nugetPath', - }, propsData: { packageEntity, }, + stubs: { GlSprintf }, }); } @@ -71,5 +70,12 @@ describe('NugetInstallation', () => { trackingAction: TRACKING_ACTION_COPY_NUGET_SETUP_COMMAND, }); }); + + it('it has docs link', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: NUGET_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js index 042b2026199..f8a4ba8f3bc 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_files_spec.js @@ -28,8 +28,8 @@ describe('Package Files', () => { const createComponent = ({ packageFiles = [file], canDelete = true } = {}) => { wrapper = mountExtended(PackageFiles, { - provide: { canDelete }, propsData: { + canDelete, packageFiles, }, stubs: { diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js index 410c1b65348..f2fef6436a6 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/pypi_installation_spec.js @@ -1,3 +1,4 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { packageData } from 'jest/packages_and_registries/package_registry/mock_data'; import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue'; @@ -6,6 +7,7 @@ import { PACKAGE_TYPE_PYPI, TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND, TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, + PYPI_HELP_PATH, } from '~/packages_and_registries/package_registry/constants'; const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; @@ -13,9 +15,9 @@ const packageEntity = { ...packageData(), packageType: PACKAGE_TYPE_PYPI }; describe('PypiInstallation', () => { let wrapper; - const pipCommandStr = 'pip install @gitlab-org/package-15 --extra-index-url pypiPath'; + const pipCommandStr = `pip install @gitlab-org/package-15 --extra-index-url ${packageEntity.pypiUrl}`; const pypiSetupStr = `[gitlab] -repository = pypiSetupPath +repository = ${packageEntity.pypiSetupUrl} username = __token__ password = <your personal access token>`; @@ -23,17 +25,16 @@ password = <your personal access token>`; const setupInstruction = () => wrapper.findByTestId('pypi-setup-content'); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findSetupDocsLink = () => wrapper.findComponent(GlLink); function createComponent() { wrapper = shallowMountExtended(PypiInstallation, { - provide: { - pypiHelpPath: 'pypiHelpPath', - pypiPath: 'pypiPath', - pypiSetupPath: 'pypiSetupPath', - }, propsData: { packageEntity, }, + stubs: { + GlSprintf, + }, }); } @@ -76,5 +77,12 @@ password = <your personal access token>`; trackingAction: TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND, }); }); + + it('has a link to the docs', () => { + expect(findSetupDocsLink().attributes()).toMatchObject({ + href: PYPI_HELP_PATH, + target: '_blank', + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap index 165ee962417..18a99f70756 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -22,16 +22,20 @@ exports[`packages_list_row renders 1`] = ` <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0" > - <gl-link-stub + <router-link-stub + ariacurrentvalue="page" class="gl-text-body gl-min-w-0" data-qa-selector="package_link" - href="http://gdk.test:3000/gitlab-org/gitlab-test/-/packages/111" + data-testid="details-link" + event="click" + tag="a" + to="[object Object]" > <gl-truncate-stub position="end" text="@gitlab-org/package-15" /> - </gl-link-stub> + </router-link-stub> <!----> diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 292667ec47c..9467a613b2a 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -1,7 +1,11 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; +import { GlSprintf } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; @@ -13,6 +17,9 @@ import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { packageData, packagePipelines, packageProject, packageTags } from '../../mock_data'; +const localVue = createLocalVue(); +localVue.use(VueRouter); + describe('packages_list_row', () => { let wrapper; @@ -28,7 +35,7 @@ describe('packages_list_row', () => { const findDeleteButton = () => wrapper.findByTestId('action-delete'); const findPackageIconAndName = () => wrapper.find(PackageIconAndName); const findListItem = () => wrapper.findComponent(ListItem); - const findPackageLink = () => wrapper.findComponent(GlLink); + const findPackageLink = () => wrapper.findByTestId('details-link'); const findWarningIcon = () => wrapper.findByTestId('warning-icon'); const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); const findPublishMethod = () => wrapper.findComponent(PublishMethod); @@ -40,6 +47,7 @@ describe('packages_list_row', () => { provide = defaultProvide, } = {}) => { wrapper = shallowMountExtended(PackagesListRow, { + localVue, provide, stubs: { ListItem, @@ -63,6 +71,15 @@ describe('packages_list_row', () => { expect(wrapper.element).toMatchSnapshot(); }); + it('has a link to navigate to the details page', () => { + mountComponent(); + + expect(findPackageLink().props()).toMatchObject({ + event: 'click', + to: { name: 'details', params: { id: getIdFromGraphQLId(packageWithoutTags.id) } }, + }); + }); + describe('tags', () => { it('renders package tags when a package has tags', () => { mountComponent({ packageEntity: packageWithTags }); @@ -120,7 +137,7 @@ describe('packages_list_row', () => { }); it('details link is disabled', () => { - expect(findPackageLink().attributes('disabled')).toBe('true'); + expect(findPackageLink().props('event')).toBe(''); }); it('has a warning icon', () => { diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 4c23b52b8a2..c6a59f20998 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -120,12 +120,22 @@ export const packageVersions = () => [ export const packageData = (extend) => ({ id: 'gid://gitlab/Packages::Package/111', + canDestroy: true, name: '@gitlab-org/package-15', packageType: 'NPM', version: '1.0.0', createdAt: '2020-08-17T14:23:32Z', updatedAt: '2020-08-17T14:23:32Z', status: 'DEFAULT', + mavenUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/maven', + npmUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/npm', + nugetUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/nuget/index.json', + composerConfigRepositoryUrl: 'gdk.test/22', + composerUrl: 'http://gdk.test:3000/api/v4/group/22/-/packages/composer/packages.json', + conanUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/conan', + pypiUrl: + 'http://__token__:<your_personal_token>@gdk.test:3000/api/v4/projects/1/packages/pypi/simple', + pypiSetupUrl: 'http://gdk.test:3000/api/v4/projects/1/packages/pypi', ...extend, }); @@ -185,6 +195,7 @@ export const packageDetailsQuery = (extendPackage) => ({ project: { id: '1', path: 'projectPath', + name: 'gitlab-test', }, tags: { nodes: packageTags(), diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap index dbe3c70c3cb..ed96abe24b1 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap @@ -11,10 +11,10 @@ exports[`PackagesListApp renders 1`] = ` <div> <section - class="row empty-state text-center" + class="gl-display-flex empty-state gl-text-center gl-flex-direction-column" > <div - class="col-12" + class="gl-max-w-full" > <div class="svg-250 svg-content" @@ -29,10 +29,10 @@ exports[`PackagesListApp renders 1`] = ` </div> <div - class="col-12" + class="gl-max-w-full gl-m-auto" > <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="gl-mx-auto gl-my-0 gl-p-5" > <h1 class="gl-font-size-h-display gl-line-height-36 h4" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index 0bea84693f6..637e2edf3be 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -9,7 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; -import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; +import PackagesApp from '~/packages_and_registries/package_registry/pages/details.vue'; import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue'; @@ -36,7 +36,7 @@ import { packageFiles, packageDestroyFileMutation, packageDestroyFileMutationError, -} from '../../mock_data'; +} from '../mock_data'; jest.mock('~/flash'); useMockLocationHelper(); @@ -47,21 +47,22 @@ describe('PackagesApp', () => { let wrapper; let apolloProvider; + const breadCrumbState = { + updateName: jest.fn(), + }; + const provide = { packageId: '111', - titleComponent: 'PackageTitle', - projectName: 'projectName', - canDelete: 'canDelete', - svgPath: 'svgPath', - npmPath: 'npmPath', - npmHelpPath: 'npmHelpPath', + emptyListIllustration: 'svgPath', projectListUrl: 'projectListUrl', groupListUrl: 'groupListUrl', + breadCrumbState, }; function createComponent({ resolver = jest.fn().mockResolvedValue(packageDetailsQuery()), fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()), + routeId = '1', } = {}) { localVue.use(VueApollo); @@ -87,6 +88,13 @@ describe('PackagesApp', () => { GlTabs, GlTab, }, + mocks: { + $route: { + params: { + id: routeId, + }, + }, + }, }); } @@ -149,7 +157,7 @@ describe('PackagesApp', () => { expect(findPackageHistory().exists()).toBe(true); expect(findPackageHistory().props()).toMatchObject({ packageEntity: expect.objectContaining(packageData()), - projectName: provide.projectName, + projectName: packageDetailsQuery().data.package.project.name, }); }); @@ -175,9 +183,18 @@ describe('PackagesApp', () => { }); }); + it('calls the appropriate function to set the breadcrumbState', async () => { + const { name, version } = packageData(); + createComponent(); + + await waitForPromises(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(`${name} v ${version}`); + }); + describe('delete package', () => { const originalReferrer = document.referrer; - const setReferrer = (value = provide.projectName) => { + const setReferrer = (value = packageDetailsQuery().data.package.project.name) => { Object.defineProperty(document, 'referrer', { value, configurable: true, @@ -244,6 +261,7 @@ describe('PackagesApp', () => { expect(findPackageFiles().exists()).toBe(true); expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile); + expect(findPackageFiles().props('canDelete')).toBe(packageData().canDestroy); }); it('does not render the package files table when the package is composer', async () => { diff --git a/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/publish_method_spec.js.snap index 5f243799bae..5f243799bae 100644 --- a/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/publish_method_spec.js.snap diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap index 7044c1285d8..ceae8eebaef 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap +++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap @@ -1,7 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` -<div +<nav + aria-label="Breadcrumb" class="gl-breadcrumbs" > @@ -24,19 +25,25 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` class="gl-breadcrumb-separator" data-testid="separator" > - <svg - aria-hidden="true" - class="gl-icon s8" - data-testid="angle-right-icon" - role="img" + <span + class="gl-mx-n5" > - <use - href="#angle-right" - /> - </svg> + <svg + aria-hidden="true" + class="gl-icon s8" + data-testid="angle-right-icon" + role="img" + > + <use + href="#angle-right" + /> + </svg> + </span> </span> </a> </li> + + <!----> <li class="breadcrumb-item gl-breadcrumb-item" > @@ -52,12 +59,15 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = ` <!----> </a> </li> + + <!----> </ol> -</div> +</nav> `; exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` -<div +<nav + aria-label="Breadcrumb" class="gl-breadcrumbs" > @@ -79,6 +89,8 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` <!----> </a> </li> + + <!----> </ol> -</div> +</nav> `; diff --git a/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js b/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js index d6d1970cb12..d6d1970cb12 100644 --- a/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/package_icon_and_name_spec.js diff --git a/spec/frontend/packages_and_registries/shared/package_path_spec.js b/spec/frontend/packages_and_registries/shared/components/package_path_spec.js index 93425d4f399..93425d4f399 100644 --- a/spec/frontend/packages_and_registries/shared/package_path_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/package_path_spec.js diff --git a/spec/frontend/packages_and_registries/shared/package_tags_spec.js b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js index 33e96c0775e..33e96c0775e 100644 --- a/spec/frontend/packages_and_registries/shared/package_tags_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/package_tags_spec.js diff --git a/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js index 0005162e0bb..0005162e0bb 100644 --- a/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/packages_list_loader_spec.js diff --git a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js new file mode 100644 index 00000000000..bd492a5ae8f --- /dev/null +++ b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js @@ -0,0 +1,145 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import component from '~/packages_and_registries/shared/components/persisted_search.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; + +jest.mock('~/packages_and_registries/shared/utils'); + +useMockLocationHelper(); + +describe('Persisted Search', () => { + let wrapper; + + const defaultQueryParamsMock = { + filters: ['foo'], + sorting: { sort: 'desc', orderBy: 'test' }, + }; + + const defaultProps = { + sortableFields: [ + { orderBy: 'test', label: 'test' }, + { orderBy: 'foo', label: 'foo' }, + ], + defaultOrder: 'test', + defaultSort: 'asc', + }; + + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findUrlSync = () => wrapper.findComponent(UrlSync); + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(component, { + propsData, + stubs: { + UrlSync, + }, + }); + }; + + beforeEach(() => { + extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a registry search component', async () => { + mountComponent(); + + await nextTick(); + + expect(findRegistrySearch().exists()).toBe(true); + }); + + it('registry search is mounted after mount', async () => { + mountComponent(); + + expect(findRegistrySearch().exists()).toBe(false); + }); + + it('has a UrlSync component', () => { + mountComponent(); + + expect(findUrlSync().exists()).toBe(true); + }); + + it('on sorting:changed emits update event and update internal sort', async () => { + const payload = { sort: 'desc', orderBy: 'test' }; + + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('sorting:changed', payload); + + await nextTick(); + + expect(findRegistrySearch().props('sorting')).toMatchObject(payload); + + // there is always a first call on mounted that emits up default values + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: ['foo'], + sort: 'TEST_DESC', + }, + ]); + }); + + it('on filter:changed updates the filters', async () => { + const payload = ['foo']; + + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('filter:changed', payload); + + await nextTick(); + + expect(findRegistrySearch().props('filter')).toEqual(['foo']); + }); + + it('on filter:submit emits update event', async () => { + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('filter:submit'); + + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: ['foo'], + sort: 'TEST_DESC', + }, + ]); + }); + + it('on query:changed calls updateQuery from UrlSync', async () => { + jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); + + mountComponent(); + + await nextTick(); + + findRegistrySearch().vm.$emit('query:changed'); + + expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); + }); + + it('sets the component sorting and filtering based on the querystring', async () => { + mountComponent(); + + await nextTick(); + + expect(getQueryParams).toHaveBeenCalled(); + + expect(findRegistrySearch().props()).toMatchObject({ + filter: defaultQueryParamsMock.filters, + sorting: defaultQueryParamsMock.sorting, + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/shared/publish_method_spec.js b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js index fa8f8f7641a..fa8f8f7641a 100644 --- a/spec/frontend/packages_and_registries/shared/publish_method_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/publish_method_spec.js diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js index e5a8438f23f..6dfe116c285 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import component from '~/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue'; +import component from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; describe('Registry Breadcrumb', () => { let wrapper; diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index 5bba98bdf96..6a7ce80ec5a 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -94,13 +94,13 @@ describe('Todos', () => { }); it('updates pending text', () => { - expect(document.querySelector('.js-todos-pending .badge').innerHTML).toEqual( + expect(document.querySelector('.js-todos-pending .js-todos-badge').innerHTML).toEqual( addDelimiter(TEST_COUNT_BIG), ); }); it('updates done text', () => { - expect(document.querySelector('.js-todos-done .badge').innerHTML).toEqual( + expect(document.querySelector('.js-todos-done .js-todos-badge').innerHTML).toEqual( addDelimiter(TEST_DONE_COUNT_BIG), ); }); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index 53c1733eab9..b700c255e8c 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -38,14 +38,14 @@ describe('Timezone Dropdown', () => { const tzStr = '[UTC + 5.5] Sri Jayawardenepura'; const tzValue = 'Asia/Colombo'; - expect($inputEl.val()).toBe('UTC'); + expect($inputEl.val()).toBe('Etc/UTC'); $(`${tzListSel}:contains('${tzStr}')`, $wrapper).trigger('click'); const val = $inputEl.val(); expect(val).toBe(tzValue); - expect(val).not.toBe('UTC'); + expect(val).not.toBe('Etc/UTC'); }); it('will format data array of timezones into a list of offsets', () => { @@ -67,7 +67,7 @@ describe('Timezone Dropdown', () => { it('will default the timezone to UTC', () => { const tz = $inputEl.val(); - expect(tz).toBe('UTC'); + expect(tz).toBe('Etc/UTC'); }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 0020269e4e7..8a9bb025d55 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -7,6 +7,7 @@ import { visibilityLevelDescriptions, visibilityOptions, } from '~/pages/projects/shared/permissions/constants'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; const defaultProps = { currentSettings: { @@ -47,6 +48,8 @@ const defaultProps = { packagesAvailable: false, packagesHelpPath: '/help/user/packages/index', requestCveAvailable: true, + confirmationPhrase: 'my-fake-project', + showVisibilityConfirmModal: false, }; describe('Settings Panel', () => { @@ -104,6 +107,7 @@ describe('Settings Panel', () => { ); const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' }); const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' }); + const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); afterEach(() => { wrapper.destroy(); @@ -177,6 +181,44 @@ describe('Settings Panel', () => { expect(findRequestAccessEnabledInput().exists()).toBe(false); }); + + it('does not require confirmation if the visibility is reduced', async () => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.INTERNAL }, + }); + + expect(findConfirmDangerButton().exists()).toBe(false); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findConfirmDangerButton().exists()).toBe(false); + }); + + describe('showVisibilityConfirmModal=true', () => { + beforeEach(() => { + wrapper = mountComponent({ + currentSettings: { visibilityLevel: visibilityOptions.INTERNAL }, + showVisibilityConfirmModal: true, + }); + }); + + it('will render the confirmation dialog if the visibility is reduced', async () => { + expect(findConfirmDangerButton().exists()).toBe(false); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + + expect(findConfirmDangerButton().exists()).toBe(true); + }); + + it('emits the `confirm` event when the reduce visibility warning is confirmed', async () => { + expect(wrapper.emitted('confirm')).toBeUndefined(); + + await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE); + await findConfirmDangerButton().vm.$emit('confirm'); + + expect(wrapper.emitted('confirm')).toHaveLength(1); + }); + }); }); describe('Issues settings', () => { diff --git a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js index 2c8eb8e459f..04f53e048ed 100644 --- a/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js +++ b/spec/frontend/pages/shared/nav/sidebar_tracking_spec.js @@ -57,9 +57,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('is-over', 'is-showing-fly-out'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Fly out', }), @@ -74,9 +74,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { submenuList.classList.add('fly-out-list'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu_item', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu_item', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Fly out', }), @@ -92,9 +92,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('active'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Expanded', }), @@ -108,9 +108,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('active'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu_item', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu_item', + extra: JSON.stringify({ sidebar_display: 'Expanded', menu_display: 'Expanded', }), @@ -131,9 +131,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { menu.classList.add('is-over', 'is-showing-fly-out'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu', + extra: JSON.stringify({ sidebar_display: 'Collapsed', menu_display: 'Fly out', }), @@ -148,9 +148,9 @@ describe('~/pages/shared/nav/sidebar_tracking.js', () => { submenuList.classList.add('fly-out-list'); menuLink.click(); - expect(menu.dataset).toMatchObject({ - trackAction: 'click_menu_item', - trackExtra: JSON.stringify({ + expect(menu).toHaveTrackingAttributes({ + action: 'click_menu_item', + extra: JSON.stringify({ sidebar_display: 'Collapsed', menu_display: 'Fly out', }), diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index f4236146d33..fd581eebd1e 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal, GlAlert, GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -31,25 +31,28 @@ describe('WikiForm', () => { const findContent = () => wrapper.find('#wiki_content'); const findMessage = () => wrapper.find('#wiki_message'); const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button'); - const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' }); - const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' }); + const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button'); + const findUseNewEditorButton = () => wrapper.findByText('Use the new editor'); const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button'); - const findDismissContentEditorAlertButton = () => - wrapper.findByRole('button', { name: 'Try this later' }); + const findDismissContentEditorAlertButton = () => wrapper.findByText('Try this later'); const findSwitchToOldEditorButton = () => wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' }); - const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'Learn more.' }); + const findTitleHelpLink = () => wrapper.findByText('Learn more.'); const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link'); const findContentEditor = () => wrapper.findComponent(ContentEditor); const findClassicEditor = () => wrapper.findComponent(MarkdownField); const setFormat = (value) => { const format = findFormat(); - format.find(`option[value=${value}]`).setSelected(); - format.element.dispatchEvent(new Event('change')); + + return format.find(`option[value=${value}]`).setSelected(); }; - const triggerFormSubmit = () => findForm().element.dispatchEvent(new Event('submit')); + const triggerFormSubmit = () => { + findForm().element.dispatchEvent(new Event('submit')); + + return nextTick(); + }; const dispatchBeforeUnload = () => { const e = new Event('beforeunload'); @@ -84,34 +87,14 @@ describe('WikiForm', () => { Org: 'org', }; - function createWrapper( - persisted = false, - { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {}, - ) { - wrapper = extendedWrapper( - mount( - WikiForm, - { - provide: { - formatOptions, - glFeatures, - pageInfo: { - ...(persisted ? pageInfoPersisted : pageInfoNew), - ...pageInfo, - }, - }, - }, - { attachToDocument: true }, - ), - ); - } - - const createShallowWrapper = ( + function createWrapper({ + mountFn = shallowMount, persisted = false, - { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {}, - ) => { + pageInfo, + glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false }, + } = {}) { wrapper = extendedWrapper( - shallowMount(WikiForm, { + mountFn(WikiForm, { provide: { formatOptions, glFeatures, @@ -122,10 +105,12 @@ describe('WikiForm', () => { }, stubs: { MarkdownField, + GlAlert, + GlButton, }, }), ); - }; + } beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); @@ -147,26 +132,24 @@ describe('WikiForm', () => { `( 'updates the commit message to $message when title is $title and persisted=$persisted', async ({ title, message, persisted }) => { - createWrapper(persisted); - - findTitle().setValue(title); + createWrapper({ persisted }); - await wrapper.vm.$nextTick(); + await findTitle().setValue(title); expect(findMessage().element.value).toBe(message); }, ); it('sets the commit message to "Update My page" when the page first loads when persisted', async () => { - createWrapper(true); + createWrapper({ persisted: true }); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findMessage().element.value).toBe('Update My page'); }); it('does not trim page content by default', () => { - createWrapper(true); + createWrapper({ persisted: true }); expect(findContent().element.value).toBe(' My page content '); }); @@ -178,20 +161,16 @@ describe('WikiForm', () => { ${'asciidoc'} | ${'link:page-slug[Link title]'} ${'org'} | ${'[[page-slug]]'} `('updates the link help message when format=$value is selected', async ({ value, text }) => { - createWrapper(); + createWrapper({ mountFn: mount }); - setFormat(value); - - await wrapper.vm.$nextTick(); + await setFormat(value); expect(wrapper.text()).toContain(text); }); - it('starts with no unload warning', async () => { + it('starts with no unload warning', () => { createWrapper(); - await wrapper.vm.$nextTick(); - const e = dispatchBeforeUnload(); expect(typeof e.returnValue).not.toBe('string'); expect(e.preventDefault).not.toHaveBeenCalled(); @@ -203,20 +182,16 @@ describe('WikiForm', () => { ${false} | ${'You can specify the full path for the new file. We will automatically create any missing directories.'} | ${'/help/user/project/wiki/index#create-a-new-wiki-page'} `( 'shows appropriate title help text and help link for when persisted=$persisted', - async ({ persisted, titleHelpLink, titleHelpText }) => { - createWrapper(persisted); - - await wrapper.vm.$nextTick(); + ({ persisted, titleHelpLink, titleHelpText }) => { + createWrapper({ persisted }); expect(wrapper.text()).toContain(titleHelpText); expect(findTitleHelpLink().attributes().href).toBe(titleHelpLink); }, ); - it('shows correct link for wiki specific markdown docs', async () => { - createWrapper(); - - await wrapper.vm.$nextTick(); + it('shows correct link for wiki specific markdown docs', () => { + createWrapper({ mountFn: mount }); expect(findMarkdownHelpLink().attributes().href).toBe( '/help/user/markdown#wiki-specific-markdown', @@ -225,12 +200,11 @@ describe('WikiForm', () => { describe('when wiki content is updated', () => { beforeEach(async () => { - createWrapper(true); + createWrapper({ mountFn: mount, persisted: true }); const input = findContent(); - input.setValue(' Lorem ipsum dolar sit! '); - await input.trigger('input'); + await input.setValue(' Lorem ipsum dolar sit! '); }); it('sets before unload warning', () => { @@ -241,17 +215,15 @@ describe('WikiForm', () => { describe('form submit', () => { beforeEach(async () => { - triggerFormSubmit(); - - await wrapper.vm.$nextTick(); + await triggerFormSubmit(); }); - it('when form submitted, unsets before unload warning', async () => { + it('when form submitted, unsets before unload warning', () => { const e = dispatchBeforeUnload(); expect(e.preventDefault).not.toHaveBeenCalled(); }); - it('triggers wiki format tracking event', async () => { + it('triggers wiki format tracking event', () => { expect(trackingSpy).toHaveBeenCalledTimes(1); }); @@ -264,22 +236,20 @@ describe('WikiForm', () => { describe('submit button state', () => { it.each` title | content | buttonState | disabledAttr - ${'something'} | ${'something'} | ${'enabled'} | ${undefined} - ${''} | ${'something'} | ${'disabled'} | ${'disabled'} - ${'something'} | ${''} | ${'disabled'} | ${'disabled'} - ${''} | ${''} | ${'disabled'} | ${'disabled'} - ${' '} | ${' '} | ${'disabled'} | ${'disabled'} + ${'something'} | ${'something'} | ${'enabled'} | ${false} + ${''} | ${'something'} | ${'disabled'} | ${true} + ${'something'} | ${''} | ${'disabled'} | ${true} + ${''} | ${''} | ${'disabled'} | ${true} + ${' '} | ${' '} | ${'disabled'} | ${true} `( "when title='$title', content='$content', then the button is $buttonState'", async ({ title, content, disabledAttr }) => { createWrapper(); - findTitle().setValue(title); - findContent().setValue(content); + await findTitle().setValue(title); + await findContent().setValue(content); - await wrapper.vm.$nextTick(); - - expect(findSubmitButton().attributes().disabled).toBe(disabledAttr); + expect(findSubmitButton().props().disabled).toBe(disabledAttr); }, ); @@ -288,7 +258,7 @@ describe('WikiForm', () => { ${true} | ${'Save changes'} ${false} | ${'Create page'} `('when persisted=$persisted, label is set to $buttonLabel', ({ persisted, buttonLabel }) => { - createWrapper(persisted); + createWrapper({ persisted }); expect(findSubmitButton().text()).toBe(buttonLabel); }); @@ -302,7 +272,7 @@ describe('WikiForm', () => { `( 'when persisted=$persisted, redirects the user to appropriate path', ({ persisted, redirectLink }) => { - createWrapper(persisted); + createWrapper({ persisted }); expect(findCancelButton().attributes().href).toBe(redirectLink); }, @@ -311,7 +281,7 @@ describe('WikiForm', () => { describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => { beforeEach(() => { - createShallowWrapper(true, { + createWrapper({ glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false }, }); }); @@ -323,7 +293,7 @@ describe('WikiForm', () => { describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => { beforeEach(() => { - createShallowWrapper(true, { + createWrapper({ glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true }, }); }); @@ -404,10 +374,6 @@ describe('WikiForm', () => { }); describe('wiki content editor', () => { - beforeEach(() => { - createWrapper(true); - }); - it.each` format | buttonExists ${'markdown'} | ${true} @@ -415,15 +381,17 @@ describe('WikiForm', () => { `( 'gl-alert containing "use new editor" button exists: $buttonExists if format is $format', async ({ format, buttonExists }) => { - setFormat(format); + createWrapper(); - await wrapper.vm.$nextTick(); + await setFormat(format); expect(findUseNewEditorButton().exists()).toBe(buttonExists); }, ); it('gl-alert containing "use new editor" button is dismissed on clicking dismiss button', async () => { + createWrapper(); + await findDismissContentEditorAlertButton().trigger('click'); expect(findUseNewEditorButton().exists()).toBe(false); @@ -442,22 +410,24 @@ describe('WikiForm', () => { ); }; - it('shows classic editor by default', assertOldEditorIsVisible); + it('shows classic editor by default', () => { + createWrapper({ persisted: true }); + + assertOldEditorIsVisible(); + }); describe('switch format to rdoc', () => { beforeEach(async () => { - setFormat('rdoc'); + createWrapper({ persisted: true }); - await wrapper.vm.$nextTick(); + await setFormat('rdoc'); }); it('continues to show the classic editor', assertOldEditorIsVisible); describe('switch format back to markdown', () => { beforeEach(async () => { - setFormat('rdoc'); - - await wrapper.vm.$nextTick(); + await setFormat('markdown'); }); it( @@ -469,6 +439,7 @@ describe('WikiForm', () => { describe('clicking "use new editor": editor fails to load', () => { beforeEach(async () => { + createWrapper({ mountFn: mount }); mock.onPost(/preview-markdown/).reply(400); await findUseNewEditorButton().trigger('click'); @@ -494,10 +465,12 @@ describe('WikiForm', () => { }); describe('clicking "use new editor": editor loads successfully', () => { - beforeEach(() => { + beforeEach(async () => { + createWrapper({ persisted: true, mountFn: mount }); + mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' }); - findUseNewEditorButton().trigger('click'); + await findUseNewEditorButton().trigger('click'); }); it('shows a tip to send feedback', () => { @@ -542,46 +515,40 @@ describe('WikiForm', () => { }); it('unsets before unload warning on form submit', async () => { - triggerFormSubmit(); - - await nextTick(); + await triggerFormSubmit(); const e = dispatchBeforeUnload(); expect(e.preventDefault).not.toHaveBeenCalled(); }); - }); - it('triggers tracking events on form submit', async () => { - triggerFormSubmit(); + it('triggers tracking events on form submit', async () => { + await triggerFormSubmit(); - await wrapper.vm.$nextTick(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { - label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, - }); + expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, + }); - expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { - label: WIKI_FORMAT_LABEL, - extra: { - value: findFormat().element.value, - old_format: pageInfoPersisted.format, - project_path: pageInfoPersisted.path, - }, + expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, { + label: WIKI_FORMAT_LABEL, + extra: { + value: findFormat().element.value, + old_format: pageInfoPersisted.format, + project_path: pageInfoPersisted.path, + }, + }); }); - }); - - it('updates content from content editor on form submit', async () => { - // old value - expect(findContent().element.value).toBe(' My page content '); - // wait for content editor to load - await waitForPromises(); + it('updates content from content editor on form submit', async () => { + // old value + expect(findContent().element.value).toBe(' My page content '); - triggerFormSubmit(); + // wait for content editor to load + await waitForPromises(); - await wrapper.vm.$nextTick(); + await triggerFormSubmit(); - expect(findContent().element.value).toBe('hello **world**'); + expect(findContent().element.value).toBe('hello **world**'); + }); }); describe('clicking "switch to classic editor"', () => { diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index cab4810cbf1..f15d5f334d6 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -17,19 +17,12 @@ describe('Pipeline Editor | Text editor component', () => { let editorReadyListener; let mockUse; let mockRegisterCiSchema; + let mockEditorInstance; + let editorInstanceDetail; const MockSourceEditor = { template: '<div/>', props: ['value', 'fileName'], - mounted() { - this.$emit(EDITOR_READY_EVENT); - }, - methods: { - getEditor: () => ({ - use: mockUse, - registerCiSchema: mockRegisterCiSchema, - }), - }, }; const createComponent = (glFeatures = {}, mountFn = shallowMount) => { @@ -58,6 +51,21 @@ describe('Pipeline Editor | Text editor component', () => { const findEditor = () => wrapper.findComponent(MockSourceEditor); + beforeEach(() => { + editorReadyListener = jest.fn(); + mockUse = jest.fn(); + mockRegisterCiSchema = jest.fn(); + mockEditorInstance = { + use: mockUse, + registerCiSchema: mockRegisterCiSchema, + }; + editorInstanceDetail = { + detail: { + instance: mockEditorInstance, + }, + }; + }); + afterEach(() => { wrapper.destroy(); @@ -67,10 +75,6 @@ describe('Pipeline Editor | Text editor component', () => { describe('template', () => { beforeEach(() => { - editorReadyListener = jest.fn(); - mockUse = jest.fn(); - mockRegisterCiSchema = jest.fn(); - createComponent(); }); @@ -87,7 +91,7 @@ describe('Pipeline Editor | Text editor component', () => { }); it('bubbles up events', () => { - findEditor().vm.$emit(EDITOR_READY_EVENT); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); expect(editorReadyListener).toHaveBeenCalled(); }); @@ -97,11 +101,7 @@ describe('Pipeline Editor | Text editor component', () => { describe('when `schema_linting` feature flag is on', () => { beforeEach(() => { createComponent({ schemaLinting: true }); - // Since the editor will have already mounted, the event will have fired. - // To ensure we properly test this, we clear the mock and re-remit the event. - mockRegisterCiSchema.mockClear(); - mockUse.mockClear(); - findEditor().vm.$emit(EDITOR_READY_EVENT); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); it('configures editor with syntax highlight', () => { @@ -113,7 +113,7 @@ describe('Pipeline Editor | Text editor component', () => { describe('when `schema_linting` feature flag is off', () => { beforeEach(() => { createComponent(); - findEditor().vm.$emit(EDITOR_READY_EVENT); + findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail); }); it('does not call the register CI schema function', () => { diff --git a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js index fd8a100bb2c..570323826d1 100644 --- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js @@ -1,40 +1,61 @@ +import VueApollo from 'vue-apollo'; import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import { escape } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { sprintf } from '~/locale'; import ValidationSegment, { i18n, } from '~/pipeline_editor/components/header/validation_segment.vue'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import { CI_CONFIG_STATUS_INVALID, EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_INVALID, EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, EDITOR_APP_STATUS_VALID, } from '~/pipeline_editor/constants'; -import { mockYmlHelpPagePath, mergeUnwrappedCiConfig, mockCiYml } from '../../mock_data'; +import { + mergeUnwrappedCiConfig, + mockCiYml, + mockLintUnavailableHelpPagePath, + mockYmlHelpPagePath, +} from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Validation segment component', () => { let wrapper; - const createComponent = ({ props = {}, appStatus }) => { + const mockApollo = createMockApollo(); + + const createComponent = ({ props = {}, appStatus = EDITOR_APP_STATUS_INVALID }) => { + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getAppStatus, + data: { + app: { + __typename: 'PipelineEditorApp', + status: appStatus, + }, + }, + }); + wrapper = extendedWrapper( shallowMount(ValidationSegment, { + localVue, + apolloProvider: mockApollo, provide: { ymlHelpPagePath: mockYmlHelpPagePath, + lintUnavailableHelpPagePath: mockLintUnavailableHelpPagePath, }, propsData: { ciConfig: mergeUnwrappedCiConfig(), ciFileContent: mockCiYml, ...props, }, - // Simulate graphQL client query result - data() { - return { - appStatus, - }; - }, }), ); }; @@ -92,6 +113,7 @@ describe('Validation segment component', () => { appStatus: EDITOR_APP_STATUS_INVALID, }); }); + it('has warning icon', () => { expect(findIcon().props('name')).toBe('warning-solid'); }); @@ -149,4 +171,28 @@ describe('Validation segment component', () => { }); }); }); + + describe('when the lint service is unavailable', () => { + beforeEach(() => { + createComponent({ + appStatus: EDITOR_APP_STATUS_LINT_UNAVAILABLE, + props: { + ciConfig: {}, + }, + }); + }); + + it('show a message that the service is unavailable', () => { + expect(findValidationMsg().text()).toBe(i18n.unavailableValidation); + }); + + it('shows the time-out icon', () => { + expect(findIcon().props('name')).toBe('time-out'); + }); + + it('shows the learn more link', () => { + expect(findLearnMoreLink().attributes('href')).toBe(mockLintUnavailableHelpPagePath); + expect(findLearnMoreLink().text()).toBe(i18n.learnMore); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js index 3becf82ed6e..6206a0f6aed 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js @@ -75,34 +75,83 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { expect(mockChildMounted).toHaveBeenCalledWith(mockContent1); }); - describe('showing the tab content depending on `isEmpty` and `isInvalid`', () => { + describe('alerts', () => { + describe('unavailable state', () => { + beforeEach(() => { + createWrapper({ props: { isUnavailable: true } }); + }); + + it('shows the invalid alert when the status is invalid', () => { + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain(wrapper.vm.$options.i18n.unavailable); + }); + }); + + describe('invalid state', () => { + beforeEach(() => { + createWrapper({ props: { isInvalid: true } }); + }); + + it('shows the invalid alert when the status is invalid', () => { + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(wrapper.vm.$options.i18n.invalid); + }); + }); + + describe('empty state', () => { + const text = 'my custom alert message'; + + beforeEach(() => { + createWrapper({ + props: { isEmpty: true, emptyMessage: text }, + }); + }); + + it('displays an empty message', () => { + createWrapper({ + props: { isEmpty: true }, + }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe( + 'This tab will be usable when the CI/CD configuration file is populated with valid syntax.', + ); + }); + + it('can have a custom empty message', () => { + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(text); + }); + }); + }); + + describe('showing the tab content depending on `isEmpty`, `isUnavailable` and `isInvalid`', () => { it.each` - isEmpty | isInvalid | showSlotComponent | text - ${undefined} | ${undefined} | ${true} | ${'renders'} - ${false} | ${false} | ${true} | ${'renders'} - ${undefined} | ${true} | ${false} | ${'hides'} - ${true} | ${false} | ${false} | ${'hides'} - ${false} | ${true} | ${false} | ${'hides'} + isEmpty | isUnavailable | isInvalid | showSlotComponent | text + ${undefined} | ${undefined} | ${undefined} | ${true} | ${'renders'} + ${false} | ${false} | ${false} | ${true} | ${'renders'} + ${undefined} | ${true} | ${true} | ${false} | ${'hides'} + ${true} | ${false} | ${false} | ${false} | ${'hides'} + ${false} | ${true} | ${false} | ${false} | ${'hides'} + ${false} | ${false} | ${true} | ${false} | ${'hides'} `( - '$text the slot component when isEmpty:$isEmpty and isInvalid:$isInvalid', - ({ isEmpty, isInvalid, showSlotComponent }) => { + '$text the slot component when isEmpty:$isEmpty, isUnavailable:$isUnavailable and isInvalid:$isInvalid', + ({ isEmpty, isUnavailable, isInvalid, showSlotComponent }) => { createWrapper({ - props: { isEmpty, isInvalid }, + props: { isEmpty, isUnavailable, isInvalid }, }); expect(findSlotComponent().exists()).toBe(showSlotComponent); expect(findAlert().exists()).toBe(!showSlotComponent); }, ); - - it('can have a custom empty message', () => { - const text = 'my custom alert message'; - createWrapper({ props: { isEmpty: true, emptyMessage: text } }); - - const alert = findAlert(); - - expect(alert.exists()).toBe(true); - expect(alert.text()).toBe(text); - }); }); describe('user interaction', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index fc2cbdeda0a..f02f6870653 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -10,6 +10,7 @@ export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; export const mockLintHelpPagePath = '/-/lint-help'; +export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot'; export const mockYmlHelpPagePath = '/-/yml-help'; export const mockCommitMessage = 'My commit message'; diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 09d7d4f7ca6..63eca253c48 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -5,10 +5,15 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { resolvers } from '~/pipeline_editor/graphql/resolvers'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; -import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants'; +import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; +import ValidationSegment, { + i18n as validationSegmenti18n, +} from '~/pipeline_editor/components/header/validation_segment.vue'; +import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; @@ -61,11 +66,6 @@ describe('Pipeline editor app component', () => { wrapper = shallowMount(PipelineEditorApp, { provide: { ...mockProvide, ...provide }, stubs, - data() { - return { - commitSha: '', - }; - }, mocks: { $apollo: { queries: { @@ -90,17 +90,11 @@ describe('Pipeline editor app component', () => { [getLatestCommitShaQuery, mockLatestCommitShaQuery], [getPipelineQuery, mockPipelineQuery], ]; - mockApollo = createMockApollo(handlers); + + mockApollo = createMockApollo(handlers, resolvers); const options = { localVue, - data() { - return { - currentBranch: mockDefaultBranch, - lastCommitBranch: '', - appStatus: '', - }; - }, mocks: {}, apolloProvider: mockApollo, }; @@ -116,6 +110,7 @@ describe('Pipeline editor app component', () => { const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyStateButton = () => wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); + const findValidationSegment = () => wrapper.findComponent(ValidationSegment); beforeEach(() => { mockBlobContentData = jest.fn(); @@ -240,6 +235,26 @@ describe('Pipeline editor app component', () => { }); }); + describe('when the lint query returns a 500 error', () => { + beforeEach(async () => { + mockCiConfigData.mockRejectedValueOnce(new Error(500)); + await createComponentWithApollo({ + stubs: { PipelineEditorHome, PipelineEditorHeader, ValidationSegment }, + }); + }); + + it('shows that the lint service is down', () => { + expect(findValidationSegment().text()).toContain( + validationSegmenti18n.unavailableValidation, + ); + }); + + it('does not report an error or scroll to the top', () => { + expect(findAlert().exists()).toBe(false); + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + }); + describe('when the user commits', () => { const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; const updateSuccessMessage = 'Your changes have been successfully committed.'; @@ -411,94 +426,6 @@ describe('Pipeline editor app component', () => { }); }); - describe('when multiple errors occurs in a row', () => { - const updateFailureMessage = 'The GitLab CI configuration could not be updated.'; - const unknownFailureMessage = 'The CI configuration was not loaded, please try again.'; - const unknownReasons = ['Commit failed']; - const alertErrorMessage = `${updateFailureMessage} ${unknownReasons[0]}`; - - const emitError = (type = COMMIT_FAILURE, reasons = unknownReasons) => - findEditorHome().vm.$emit('showError', { - type, - reasons, - }); - - beforeEach(async () => { - mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); - mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); - mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults); - - window.scrollTo = jest.fn(); - - await createComponentWithApollo({ stubs: { PipelineEditorMessages } }); - await emitError(); - }); - - it('shows an error message for the first error', () => { - expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); - }); - - it('scrolls to the top of the page to bring attention to the error message', () => { - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); - expect(window.scrollTo).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to the top of the page if the same error occur multiple times in a row', async () => { - await emitError(); - - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); - }); - - it('scrolls to the top if the error is different', async () => { - await emitError(LOAD_FAILURE_UNKNOWN, []); - - expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); - expect(window.scrollTo).toHaveBeenCalledTimes(2); - }); - - describe('when a user dismiss the alert', () => { - beforeEach(async () => { - await findAlert().vm.$emit('dismiss'); - }); - - it('shows an error if the type is the same, but the reason is different', async () => { - const newReason = 'Something broke'; - - await emitError(COMMIT_FAILURE, [newReason]); - - expect(window.scrollTo).toHaveBeenCalledTimes(2); - expect(findAlert().text()).toMatchInterpolatedText(`${updateFailureMessage} ${newReason}`); - }); - - it('does not show an error or scroll if a new error with the same type occurs', async () => { - await emitError(); - - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(findAlert().exists()).toBe(false); - }); - - it('it shows an error and scroll when a new type is emitted', async () => { - await emitError(LOAD_FAILURE_UNKNOWN, []); - - expect(window.scrollTo).toHaveBeenCalledTimes(2); - expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); - }); - - it('it shows an error and scroll if a previously shown type happen again', async () => { - await emitError(LOAD_FAILURE_UNKNOWN, []); - - expect(window.scrollTo).toHaveBeenCalledTimes(2); - expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage); - - await emitError(); - - expect(window.scrollTo).toHaveBeenCalledTimes(3); - expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage); - }); - }); - }); - describe('when add_new_config_file query param is present', () => { const originalLocation = window.location.href; diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 99de0d2a3ef..52461885342 100644 --- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap @@ -13,6 +13,7 @@ Array [ "id": "6", "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -53,6 +54,7 @@ Array [ "id": "11", "name": "build_b", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -93,6 +95,7 @@ Array [ "id": "16", "name": "build_c", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -133,6 +136,7 @@ Array [ "id": "21", "name": "build_d 1/3", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -157,6 +161,7 @@ Array [ "id": "24", "name": "build_d 2/3", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -181,6 +186,7 @@ Array [ "id": "27", "name": "build_d 3/3", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -221,6 +227,7 @@ Array [ "id": "59", "name": "test_c", "needs": Array [], + "previousStageJobsOrNeeds": Array [], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -267,6 +274,11 @@ Array [ "build_b", "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", ], + "previousStageJobsOrNeeds": Array [ + "build_c", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -313,6 +325,13 @@ Array [ "build_b", "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", ], + "previousStageJobsOrNeeds": Array [ + "build_d 3/3", + "build_d 2/3", + "build_d 1/3", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -343,6 +362,13 @@ Array [ "build_b", "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", ], + "previousStageJobsOrNeeds": Array [ + "build_d 3/3", + "build_d 2/3", + "build_d 1/3", + "build_b", + "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", @@ -385,6 +411,9 @@ Array [ "needs": Array [ "build_b", ], + "previousStageJobsOrNeeds": Array [ + "build_b", + ], "scheduledAt": null, "status": Object { "__typename": "DetailedStatus", diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index dcbbde7bf36..41823bfdb9f 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -73,6 +73,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -118,6 +122,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -163,6 +171,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -208,6 +220,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, { __typename: 'CiJob', @@ -235,6 +251,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, { __typename: 'CiJob', @@ -262,6 +282,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -339,6 +363,27 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '37', + name: 'build_c', + }, + { + __typename: 'CiBuildNeed', + id: '38', + name: 'build_b', + }, + { + __typename: 'CiBuildNeed', + id: '39', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, }, ], }, @@ -411,6 +456,37 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '45', + name: 'build_d 3/3', + }, + { + __typename: 'CiBuildNeed', + id: '46', + name: 'build_d 2/3', + }, + { + __typename: 'CiBuildNeed', + id: '47', + name: 'build_d 1/3', + }, + { + __typename: 'CiBuildNeed', + id: '48', + name: 'build_b', + }, + { + __typename: 'CiBuildNeed', + id: '49', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, }, { __typename: 'CiJob', @@ -465,6 +541,37 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '52', + name: 'build_d 3/3', + }, + { + __typename: 'CiBuildNeed', + id: '53', + name: 'build_d 2/3', + }, + { + __typename: 'CiBuildNeed', + id: '54', + name: 'build_d 1/3', + }, + { + __typename: 'CiBuildNeed', + id: '55', + name: 'build_b', + }, + { + __typename: 'CiBuildNeed', + id: '56', + name: + 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', + }, + ], + }, }, ], }, @@ -503,6 +610,10 @@ export const mockPipelineResponse = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, }, ], }, @@ -547,6 +658,16 @@ export const mockPipelineResponse = { }, ], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiBuildNeed', + id: '65', + name: 'build_b', + }, + ], + }, }, ], }, @@ -720,6 +841,10 @@ export const wrappedPipelineReturn = { __typename: 'CiBuildNeedConnection', nodes: [], }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, status: { __typename: 'DetailedStatus', id: '84', diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js index 42adefcd0bb..bda07af4feb 100644 --- a/spec/frontend/profile/account/components/update_username_spec.js +++ b/spec/frontend/profile/account/components/update_username_spec.js @@ -79,6 +79,8 @@ describe('UpdateUsername component', () => { beforeEach(async () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ newUsername }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js index 1fec864599c..a6bcca0ccb3 100644 --- a/spec/frontend/profile/add_ssh_key_validation_spec.js +++ b/spec/frontend/profile/add_ssh_key_validation_spec.js @@ -3,18 +3,18 @@ import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh describe('AddSshKeyValidation', () => { describe('submit', () => { it('returns true if isValid is true', () => { - const addSshKeyValidation = new AddSshKeyValidation({}); - jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(true); + const addSshKeyValidation = new AddSshKeyValidation([], {}); + jest.spyOn(addSshKeyValidation, 'isPublicKey').mockReturnValue(true); - expect(addSshKeyValidation.submit()).toBeTruthy(); + expect(addSshKeyValidation.submit()).toBe(true); }); it('calls preventDefault and toggleWarning if isValid is false', () => { - const addSshKeyValidation = new AddSshKeyValidation({}); + const addSshKeyValidation = new AddSshKeyValidation([], {}); const event = { preventDefault: jest.fn(), }; - jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(false); + jest.spyOn(addSshKeyValidation, 'isPublicKey').mockReturnValue(false); jest.spyOn(addSshKeyValidation, 'toggleWarning').mockImplementation(() => {}); addSshKeyValidation.submit(event); @@ -31,14 +31,15 @@ describe('AddSshKeyValidation', () => { warningElement.classList.add('hide'); const addSshKeyValidation = new AddSshKeyValidation( + [], {}, warningElement, originalSubmitElement, ); addSshKeyValidation.toggleWarning(true); - expect(warningElement.classList.contains('hide')).toBeFalsy(); - expect(originalSubmitElement.classList.contains('hide')).toBeTruthy(); + expect(warningElement.classList.contains('hide')).toBe(false); + expect(originalSubmitElement.classList.contains('hide')).toBe(true); }); it('hides warningElement and shows originalSubmitElement if isVisible is false', () => { @@ -47,25 +48,32 @@ describe('AddSshKeyValidation', () => { originalSubmitElement.classList.add('hide'); const addSshKeyValidation = new AddSshKeyValidation( + [], {}, warningElement, originalSubmitElement, ); addSshKeyValidation.toggleWarning(false); - expect(warningElement.classList.contains('hide')).toBeTruthy(); - expect(originalSubmitElement.classList.contains('hide')).toBeFalsy(); + expect(warningElement.classList.contains('hide')).toBe(true); + expect(originalSubmitElement.classList.contains('hide')).toBe(false); }); }); describe('isPublicKey', () => { - it('returns false if probably invalid public ssh key', () => { - expect(AddSshKeyValidation.isPublicKey('nope')).toBeFalsy(); + it('returns false if value begins with an algorithm name that is unsupported', () => { + const addSshKeyValidation = new AddSshKeyValidation(['ssh-rsa', 'ssh-algorithm'], {}); + + expect(addSshKeyValidation.isPublicKey('nope key')).toBe(false); + expect(addSshKeyValidation.isPublicKey('ssh- key')).toBe(false); + expect(addSshKeyValidation.isPublicKey('unsupported-ssh-rsa key')).toBe(false); }); - it('returns true if probably valid public ssh key', () => { - expect(AddSshKeyValidation.isPublicKey('ssh-')).toBeTruthy(); - expect(AddSshKeyValidation.isPublicKey('ecdsa-sha2-')).toBeTruthy(); + it('returns true if value begins with an algorithm name that is supported', () => { + const addSshKeyValidation = new AddSshKeyValidation(['ssh-rsa', 'ssh-algorithm'], {}); + + expect(addSshKeyValidation.isPublicKey('ssh-rsa key')).toBe(true); + expect(addSshKeyValidation.isPublicKey('ssh-algorithm key')).toBe(true); }); }); }); diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js index 5cdc3d174a1..40e7d27edc8 100644 --- a/spec/frontend/project_select_combo_button_spec.js +++ b/spec/frontend/project_select_combo_button_spec.js @@ -28,7 +28,7 @@ describe('Project Select Combo Button', () => { loadFixtures(fixturePath); - testContext.newItemBtn = document.querySelector('.new-project-item-link'); + testContext.newItemBtn = document.querySelector('.js-new-project-item-link'); testContext.projectSelectInput = document.querySelector('.project-item-select'); }); @@ -120,7 +120,6 @@ describe('Project Select Combo Button', () => { const returnedVariants = testContext.method(); expect(returnedVariants.localStorageItemType).toBe('new-merge-request'); - expect(returnedVariants.defaultTextPrefix).toBe('New merge request'); expect(returnedVariants.presetTextSuffix).toBe('merge request'); }); @@ -131,7 +130,6 @@ describe('Project Select Combo Button', () => { const returnedVariants = testContext.method(); expect(returnedVariants.localStorageItemType).toBe('new-issue'); - expect(returnedVariants.defaultTextPrefix).toBe('New issue'); expect(returnedVariants.presetTextSuffix).toBe('issue'); }); }); diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index 60d36597fda..23b4cccd92c 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -65,6 +65,8 @@ describe('Author Select', () => { describe('user is searching via "filter by commit message"', () => { it('disables dropdown container', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasSearchParam: true }); return wrapper.vm.$nextTick().then(() => { @@ -73,6 +75,8 @@ describe('Author Select', () => { }); it('has correct tooltip message', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasSearchParam: true }); return wrapper.vm.$nextTick().then(() => { @@ -83,6 +87,8 @@ describe('Author Select', () => { }); it('disables dropdown', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasSearchParam: false }); return wrapper.vm.$nextTick().then(() => { @@ -103,6 +109,8 @@ describe('Author Select', () => { }); it('displays the current selected author', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentAuthor }); return wrapper.vm.$nextTick().then(() => { @@ -156,6 +164,8 @@ describe('Author Select', () => { isChecked: true, }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentAuthor }); return wrapper.vm.$nextTick().then(() => { diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js index 38e13dc5462..eb80d57fb3c 100644 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js @@ -101,6 +101,8 @@ describe('RevisionDropdown component', () => { const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem); const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ branches: ['some-branch'] }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/projects/project_find_file_spec.js index 106b41bcc02..9c1000039b1 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/projects/project_find_file_spec.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import { TEST_HOST } from 'helpers/test_constants'; import { sanitize } from '~/lib/dompurify'; import axios from '~/lib/utils/axios_utils'; -import ProjectFindFile from '~/project_find_file'; +import ProjectFindFile from '~/projects/project_find_file'; jest.mock('~/lib/dompurify', () => ({ addHook: jest.fn(), diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index 9f9d574a8ed..d5b882bd715 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -1,6 +1,5 @@ import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; @@ -16,6 +15,7 @@ const DEFAULT_PROPS = { projectPath: 'some/project/path', isLocked: false, canLock: true, + showForkSuggestion: false, }; const DEFAULT_INJECT = { @@ -27,7 +27,7 @@ describe('BlobButtonGroup component', () => { let wrapper; const createComponent = (props = {}) => { - wrapper = shallowMount(BlobButtonGroup, { + wrapper = mountExtended(BlobButtonGroup, { propsData: { ...DEFAULT_PROPS, ...props, @@ -35,9 +35,6 @@ describe('BlobButtonGroup component', () => { provide: { ...DEFAULT_INJECT, }, - directives: { - GlModal: createMockDirective(), - }, }); }; @@ -47,7 +44,8 @@ describe('BlobButtonGroup component', () => { const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); - const findReplaceButton = () => wrapper.find('[data-testid="replace"]'); + const findDeleteButton = () => wrapper.findByTestId('delete'); + const findReplaceButton = () => wrapper.findByTestId('replace'); it('renders component', () => { createComponent(); @@ -63,6 +61,8 @@ describe('BlobButtonGroup component', () => { describe('buttons', () => { beforeEach(() => { createComponent(); + jest.spyOn(findUploadBlobModal().vm, 'show'); + jest.spyOn(findDeleteBlobModal().vm, 'show'); }); it('renders both the replace and delete button', () => { @@ -75,10 +75,37 @@ describe('BlobButtonGroup component', () => { }); it('triggers the UploadBlobModal from the replace button', () => { - const { value } = getBinding(findReplaceButton().element, 'gl-modal'); - const modalId = findUploadBlobModal().props('modalId'); + findReplaceButton().trigger('click'); + + expect(findUploadBlobModal().vm.show).toHaveBeenCalled(); + }); + + it('triggers the DeleteBlobModal from the delete button', () => { + findDeleteButton().trigger('click'); + + expect(findDeleteBlobModal().vm.show).toHaveBeenCalled(); + }); + + describe('showForkSuggestion set to true', () => { + beforeEach(() => { + createComponent({ showForkSuggestion: true }); + jest.spyOn(findUploadBlobModal().vm, 'show'); + jest.spyOn(findDeleteBlobModal().vm, 'show'); + }); + + it('does not trigger the UploadBlobModal from the replace button', () => { + findReplaceButton().trigger('click'); + + expect(findUploadBlobModal().vm.show).not.toHaveBeenCalled(); + expect(wrapper.emitted().fork).toBeTruthy(); + }); + + it('does not trigger the DeleteBlobModal from the delete button', () => { + findDeleteButton().trigger('click'); - expect(modalId).toEqual(value); + expect(findDeleteBlobModal().vm.show).not.toHaveBeenCalled(); + expect(wrapper.emitted().fork).toBeTruthy(); + }); }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 9e00a2d0408..d3b60ec3768 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -83,6 +83,8 @@ const createComponent = async (mockData = {}, mountFn = shallowMount) => { }), ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ project, isBinary }); await waitForPromises(); @@ -336,35 +338,11 @@ describe('Blob content viewer component', () => { deletePath: webPath, canPushCode: pushCode, canLock: true, - isLocked: true, + isLocked: false, emptyRepo: empty, }); }); - it.each` - canPushCode | canDownloadCode | username | canLock - ${true} | ${true} | ${'root'} | ${true} - ${false} | ${true} | ${'root'} | ${false} - ${true} | ${false} | ${'root'} | ${false} - ${true} | ${true} | ${'peter'} | ${false} - `( - 'passes the correct lock states', - async ({ canPushCode, canDownloadCode, username, canLock }) => { - gon.current_username = username; - - await createComponent( - { - pushCode: canPushCode, - downloadCode: canDownloadCode, - empty, - }, - mount, - ); - - expect(findBlobButtonGroup().props('canLock')).toBe(canLock); - }, - ); - it('does not render if not logged in', async () => { isLoggedIn.mockReturnValueOnce(false); diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/blob_controls_spec.js new file mode 100644 index 00000000000..03e389ea5cb --- /dev/null +++ b/spec/frontend/repository/components/blob_controls_spec.js @@ -0,0 +1,88 @@ +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import BlobControls from '~/repository/components/blob_controls.vue'; +import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createRouter from '~/repository/router'; +import { updateElementsVisibility } from '~/repository/utils/dom'; +import { blobControlsDataMock, refMock } from '../mock_data'; + +jest.mock('~/repository/utils/dom'); + +let router; +let wrapper; +let mockResolver; + +const localVue = createLocalVue(); + +const createComponent = async () => { + localVue.use(VueApollo); + + const project = { ...blobControlsDataMock }; + const projectPath = 'some/project'; + + router = createRouter(projectPath, refMock); + + router.replace({ name: 'blobPath', params: { path: '/some/file.js' } }); + + mockResolver = jest.fn().mockResolvedValue({ data: { project } }); + + wrapper = shallowMountExtended(BlobControls, { + localVue, + router, + apolloProvider: createMockApollo([[blobControlsQuery, mockResolver]]), + propsData: { projectPath }, + mixins: [{ data: () => ({ ref: refMock }) }], + }); + + await waitForPromises(); +}; + +describe('Blob controls component', () => { + const findFindButton = () => wrapper.findByTestId('find'); + const findBlameButton = () => wrapper.findByTestId('blame'); + const findHistoryButton = () => wrapper.findByTestId('history'); + const findPermalinkButton = () => wrapper.findByTestId('permalink'); + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + it('renders a find button with the correct href', () => { + expect(findFindButton().attributes('href')).toBe('find/file.js'); + }); + + it('renders a blame button with the correct href', () => { + expect(findBlameButton().attributes('href')).toBe('blame/file.js'); + }); + + it('renders a history button with the correct href', () => { + expect(findHistoryButton().attributes('href')).toBe('history/file.js'); + }); + + it('renders a permalink button with the correct href', () => { + expect(findPermalinkButton().attributes('href')).toBe('permalink/file.js'); + }); + + it.each` + name | path + ${'blobPathDecoded'} | ${null} + ${'treePathDecoded'} | ${'myFile.js'} + `( + 'does not render any buttons if router name is $name and router path is $path', + async ({ name, path }) => { + router.replace({ name, params: { path } }); + + await nextTick(); + + expect(findFindButton().exists()).toBe(false); + expect(findBlameButton().exists()).toBe(false); + expect(findHistoryButton().exists()).toBe(false); + expect(findPermalinkButton().exists()).toBe(false); + expect(updateElementsVisibility).toHaveBeenCalledWith('.tree-controls', true); + }, + ); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index eb957c635ac..ad2cbd70187 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -75,6 +75,8 @@ describe('Repository breadcrumbs component', () => { it('does not render add to tree dropdown when permissions are false', async () => { factory('/', { canCollaborate: false }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); await wrapper.vm.$nextTick(); @@ -100,6 +102,8 @@ describe('Repository breadcrumbs component', () => { it('renders add to tree dropdown when permissions are true', async () => { factory('/', { canCollaborate: true }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); await wrapper.vm.$nextTick(); @@ -117,6 +121,8 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); await wrapper.vm.$nextTick(); @@ -139,6 +145,8 @@ describe('Repository breadcrumbs component', () => { }); it('renders the modal once loaded', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index ebea7dde34a..fe05a981845 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -43,6 +43,8 @@ function factory(commit = createCommitData(), loading = false) { }, }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ commit }); vm.vm.$apollo.queries.commit.loading = loading; } diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js index 466eed52739..2490258a048 100644 --- a/spec/frontend/repository/components/preview/index_spec.js +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -34,6 +34,8 @@ describe('Repository file preview component', () => { name: 'README.md', }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ readme: { html: '<div class="blob">test</div>' } }); return vm.vm.$nextTick(() => { @@ -47,6 +49,8 @@ describe('Repository file preview component', () => { name: 'README.md', }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ readme: { html: '<div class="blob">test</div>' } }); return vm.vm @@ -63,6 +67,8 @@ describe('Repository file preview component', () => { name: 'README.md', }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ loading: 1 }); return vm.vm.$nextTick(() => { diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index c8dddefc4f2..2cd88944f81 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -89,6 +89,8 @@ describe('Repository table component', () => { `('renders table caption for $ref in $path', ({ path, ref }) => { factory({ path }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ ref }); return vm.vm.$nextTick(() => { diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 7f59dbfe0d1..440baa72a3c 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -40,6 +40,8 @@ function factory(propsData = {}) { }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ escapedRef: 'main' }); } diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 9c5d07eede3..00ad1fc05f6 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -46,6 +46,8 @@ describe('Repository table component', () => { it('renders file preview', async () => { factory('/'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); await vm.vm.$nextTick(); @@ -134,6 +136,8 @@ describe('Repository table component', () => { it('is not rendered if less than 1000 files', async () => { factory('/'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ fetchCounter: 5, clickedShowMore: false }); await vm.vm.$nextTick(); @@ -153,6 +157,8 @@ describe('Repository table component', () => { factory('/'); const blobs = new Array(totalBlobs).fill('fakeBlob'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ entries: { blobs }, pagesLoaded }); await vm.vm.$nextTick(); @@ -173,6 +179,8 @@ describe('Repository table component', () => { ${200} | ${100} `('exponentially increases page size, to a maximum of 100', ({ fetchCounter, pageSize }) => { factory('/'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax vm.setData({ fetchCounter }); vm.vm.fetchFiles(); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index e9dfa3cd495..6b8b0752485 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -109,6 +109,8 @@ describe('UploadBlobModal', () => { if (canPushCode) { describe('when changing the branch name', () => { it('displays the MR toggle', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ target: 'Not main' }); await wrapper.vm.$nextTick(); @@ -120,6 +122,8 @@ describe('UploadBlobModal', () => { describe('completed form', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ file: { type: 'jpg' }, filePreviewURL: 'http://file.com?format=jpg', diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 74d35daf578..a5ee17ba672 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -13,7 +13,9 @@ export const simpleViewerMock = { ideForkAndEditPath: 'some_file.js/fork/ide', canModifyBlob: true, canCurrentUserPushToBranch: true, + archived: false, storedExternally: false, + externalStorage: 'lfs', rawPath: 'some_file.js', replacePath: 'some_file.js/replace', pipelineEditorPath: '', @@ -50,7 +52,7 @@ export const projectMock = { nodes: [ { id: 'test', - path: simpleViewerMock.path, + path: 'locked_file.js', user: { id: '123', username: 'root' }, }, ], @@ -63,3 +65,22 @@ export const projectMock = { export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; export const refMock = 'default-ref'; + +export const blobControlsDataMock = { + id: '1234', + repository: { + blobs: { + nodes: [ + { + id: '5678', + findFilePath: 'find/file.js', + blamePath: 'blame/file.js', + historyPath: 'history/file.js', + permalinkPath: 'permalink/file.js', + storedExternally: false, + externalStorage: '', + }, + ], + }, + }, +}; diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js index 1a1428e8cb1..ad0bce5c9af 100644 --- a/spec/frontend/runner/runner_detail/runner_details_app_spec.js +++ b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js @@ -2,12 +2,12 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import RunnerHeader from '~/runner/components/runner_header.vue'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; -import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; +import AdminRunnerEditApp from '~//runner/admin_runner_edit/admin_runner_edit_app.vue'; import { captureException } from '~/runner/sentry_utils'; import { runnerData } from '../mock_data'; @@ -21,14 +21,14 @@ const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const localVue = createLocalVue(); localVue.use(VueApollo); -describe('RunnerDetailsApp', () => { +describe('AdminRunnerEditApp', () => { let wrapper; let mockRunnerQuery; - const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); + const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { - wrapper = mountFn(RunnerDetailsApp, { + wrapper = mountFn(AdminRunnerEditApp, { localVue, apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]), propsData: { @@ -40,7 +40,7 @@ describe('RunnerDetailsApp', () => { return waitForPromises(); }; - beforeEach(async () => { + beforeEach(() => { mockRunnerQuery = jest.fn().mockResolvedValue(runnerData); }); @@ -56,15 +56,16 @@ describe('RunnerDetailsApp', () => { }); it('displays the runner id', async () => { - await createComponentWithApollo(); + await createComponentWithApollo({ mountFn: mount }); - expect(wrapper.text()).toContain(`Runner #${mockRunnerId}`); + expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId} created`); }); - it('displays the runner type', async () => { + it('displays the runner type and status', async () => { await createComponentWithApollo({ mountFn: mount }); - expect(findRunnerTypeBadge().text()).toBe('shared'); + expect(findRunnerHeader().text()).toContain(`never contacted`); + expect(findRunnerHeader().text()).toContain(`shared`); }); describe('When there is an error', () => { @@ -73,15 +74,15 @@ describe('RunnerDetailsApp', () => { await createComponentWithApollo(); }); - it('error is reported to sentry', async () => { + it('error is reported to sentry', () => { expect(captureException).toHaveBeenCalledWith({ error: new Error('Network error: Error!'), - component: 'RunnerDetailsApp', + component: 'AdminRunnerEditApp', }); }); - it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalled(); + it('error is shown to the user', () => { + expect(createAlert).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 7015fe809b0..42be691ba4c 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -13,6 +13,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; @@ -22,23 +23,21 @@ import { CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, + GROUP_TYPE, + PROJECT_TYPE, PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; +import getRunnersCountQuery from '~/runner/graphql/get_runners_count.query.graphql'; import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { runnersData, runnersDataPaginated } from '../mock_data'; +import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; -const mockActiveRunnersCount = '2'; -const mockAllRunnersCount = '6'; -const mockInstanceRunnersCount = '3'; -const mockGroupRunnersCount = '2'; -const mockProjectRunnersCount = '1'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -53,7 +52,9 @@ localVue.use(VueApollo); describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; + let mockRunnersCountQuery; + const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerList = () => wrapper.findComponent(RunnerList); @@ -65,27 +66,28 @@ describe('AdminRunnersApp', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { - const handlers = [[getRunnersQuery, mockRunnersQuery]]; - - wrapper = mountFn(AdminRunnersApp, { - localVue, - apolloProvider: createMockApollo(handlers), - propsData: { - registrationToken: mockRegistrationToken, - activeRunnersCount: mockActiveRunnersCount, - allRunnersCount: mockAllRunnersCount, - instanceRunnersCount: mockInstanceRunnersCount, - groupRunnersCount: mockGroupRunnersCount, - projectRunnersCount: mockProjectRunnersCount, - ...props, - }, - }); + const handlers = [ + [getRunnersQuery, mockRunnersQuery], + [getRunnersCountQuery, mockRunnersCountQuery], + ]; + + wrapper = extendedWrapper( + mountFn(AdminRunnersApp, { + localVue, + apolloProvider: createMockApollo(handlers), + propsData: { + registrationToken: mockRegistrationToken, + ...props, + }, + }), + ); }; beforeEach(async () => { setWindowLocation('/admin/runners'); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); + mockRunnersCountQuery = jest.fn().mockResolvedValue(runnersCountData); createComponent(); await waitForPromises(); }); @@ -95,13 +97,71 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); - it('shows the runner tabs with a runner count', async () => { + it('shows total runner counts', async () => { createComponent({ mountFn: mount }); await waitForPromises(); + const stats = findRunnerStats().text(); + + expect(stats).toMatch('Online runners 4'); + expect(stats).toMatch('Offline runners 4'); + expect(stats).toMatch('Stale runners 4'); + }); + + it('shows the runner tabs with a runner count for each type', async () => { + mockRunnersCountQuery.mockImplementation(({ type }) => { + let count; + switch (type) { + case INSTANCE_TYPE: + count = 3; + break; + case GROUP_TYPE: + count = 2; + break; + case PROJECT_TYPE: + count = 1; + break; + default: + count = 6; + break; + } + return Promise.resolve({ data: { runners: { count } } }); + }); + + createComponent({ mountFn: mount }); + await waitForPromises(); + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( - `All ${mockAllRunnersCount} Instance ${mockInstanceRunnersCount} Group ${mockGroupRunnersCount} Project ${mockProjectRunnersCount}`, + `All 6 Instance 3 Group 2 Project 1`, + ); + }); + + it('shows the runner tabs with a formatted runner count', async () => { + mockRunnersCountQuery.mockImplementation(({ type }) => { + let count; + switch (type) { + case INSTANCE_TYPE: + count = 3000; + break; + case GROUP_TYPE: + count = 2000; + break; + case PROJECT_TYPE: + count = 1000; + break; + default: + count = 6000; + break; + } + return Promise.resolve({ data: { runners: { count } } }); + }); + + createComponent({ mountFn: mount }); + await waitForPromises(); + + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All 6,000 Instance 3,000 Group 2,000 Project 1,000`, ); }); @@ -152,12 +212,6 @@ describe('AdminRunnersApp', () => { ]); }); - it('shows the active runner count', () => { - createComponent({ mountFn: mount }); - - expect(wrapper.text()).toMatch(new RegExp(`Online Runners ${mockActiveRunnersCount}`)); - }); - describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); @@ -241,7 +295,7 @@ describe('AdminRunnersApp', () => { }); it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 95c212cb0a9..4233d86c24c 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -4,7 +4,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { captureException } from '~/runner/sentry_utils'; @@ -40,15 +40,17 @@ describe('RunnerTypeCell', () => { const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value; - const createComponent = ({ active = true } = {}, options) => { + const createComponent = (runner = {}, options) => { wrapper = extendedWrapper( shallowMount(RunnerActionCell, { propsData: { runner: { id: mockRunner.id, shortSha: mockRunner.shortSha, - adminUrl: mockRunner.adminUrl, - active, + editAdminUrl: mockRunner.editAdminUrl, + userPermissions: mockRunner.userPermissions, + active: mockRunner.active, + ...runner, }, }, localVue, @@ -101,7 +103,26 @@ describe('RunnerTypeCell', () => { it('Displays the runner edit link with the correct href', () => { createComponent(); - expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl); + expect(findEditBtn().attributes('href')).toBe(mockRunner.editAdminUrl); + }); + + it('Does not render the runner edit link when user cannot update', () => { + createComponent({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + expect(findEditBtn().exists()).toBe(false); + }); + + it('Does not render the runner edit link when editAdminUrl is not provided', () => { + createComponent({ + editAdminUrl: null, + }); + + expect(findEditBtn().exists()).toBe(false); }); }); @@ -179,7 +200,7 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); @@ -208,11 +229,22 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); }); }); + + it('Does not render the runner toggle active button when user cannot update', () => { + createComponent({ + userPermissions: { + ...mockRunner.userPermissions, + updateRunner: false, + }, + }); + + expect(findToggleActiveBtn().exists()).toBe(false); + }); }); describe('Delete action', () => { @@ -225,6 +257,10 @@ describe('RunnerTypeCell', () => { ); }); + it('Renders delete button', () => { + expect(findDeleteBtn().exists()).toBe(true); + }); + it('Delete button opens delete modal', () => { const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value; @@ -259,6 +295,18 @@ describe('RunnerTypeCell', () => { }); }); + it('Does not render the runner delete button when user cannot delete', () => { + createComponent({ + userPermissions: { + ...mockRunner.userPermissions, + deleteRunner: false, + }, + }); + + expect(findDeleteBtn().exists()).toBe(false); + expect(findRunnerDeleteModal().exists()).toBe(false); + }); + describe('When delete is clicked', () => { beforeEach(() => { findRunnerDeleteModal().vm.$emit('primary'); @@ -302,7 +350,7 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('toast notification is not shown', () => { @@ -334,7 +382,7 @@ describe('RunnerTypeCell', () => { }); it('error is shown to the user', () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js index 0d002c272b4..e75decddf70 100644 --- a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js +++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js @@ -1,14 +1,15 @@ -import { GlDropdownItem, GlLoadingIcon, GlToast } from '@gitlab/ui'; +import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -18,14 +19,18 @@ localVue.use(VueApollo); localVue.use(GlToast); const mockNewToken = 'NEW_TOKEN'; +const modalID = 'token-reset-modal'; describe('RegistrationTokenResetDropdownItem', () => { let wrapper; let runnersRegistrationTokenResetMutationHandler; let showToast; + const mockEvent = { preventDefault: jest.fn() }; const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); + const clickSubmit = () => findModal().vm.$emit('primary', mockEvent); const createComponent = ({ props, provide = {} } = {}) => { wrapper = shallowMount(RegistrationTokenResetDropdownItem, { @@ -38,6 +43,9 @@ describe('RegistrationTokenResetDropdownItem', () => { apolloProvider: createMockApollo([ [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler], ]), + directives: { + GlModal: createMockDirective(), + }, }); showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; @@ -54,8 +62,6 @@ describe('RegistrationTokenResetDropdownItem', () => { }); createComponent(); - - jest.spyOn(window, 'confirm'); }); afterEach(() => { @@ -66,6 +72,18 @@ describe('RegistrationTokenResetDropdownItem', () => { expect(findDropdownItem().exists()).toBe(true); }); + describe('modal directive integration', () => { + it('has the correct ID on the dropdown', () => { + const binding = getBinding(findDropdownItem().element, 'gl-modal'); + + expect(binding.value).toBe(modalID); + }); + + it('has the correct ID on the modal', () => { + expect(findModal().props('modalId')).toBe(modalID); + }); + }); + describe('On click and confirmation', () => { const mockGroupId = '11'; const mockProjectId = '22'; @@ -82,9 +100,8 @@ describe('RegistrationTokenResetDropdownItem', () => { props: { type }, }); - window.confirm.mockReturnValueOnce(true); - findDropdownItem().trigger('click'); + clickSubmit(); await waitForPromises(); }); @@ -114,7 +131,6 @@ describe('RegistrationTokenResetDropdownItem', () => { describe('On click without confirmation', () => { beforeEach(async () => { - window.confirm.mockReturnValueOnce(false); findDropdownItem().vm.$emit('click'); await waitForPromises(); }); @@ -142,11 +158,11 @@ describe('RegistrationTokenResetDropdownItem', () => { runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); - window.confirm.mockReturnValueOnce(true); findDropdownItem().trigger('click'); + clickSubmit(); await waitForPromises(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: `Network error: ${mockErrorMsg}`, }); expect(captureException).toHaveBeenCalledWith({ @@ -168,11 +184,11 @@ describe('RegistrationTokenResetDropdownItem', () => { }, }); - window.confirm.mockReturnValueOnce(true); findDropdownItem().trigger('click'); + clickSubmit(); await waitForPromises(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: `${mockErrorMsg} ${mockErrorMsg2}`, }); expect(captureException).toHaveBeenCalledWith({ @@ -184,8 +200,8 @@ describe('RegistrationTokenResetDropdownItem', () => { describe('Immediately after click', () => { it('shows loading state', async () => { - window.confirm.mockReturnValue(true); findDropdownItem().trigger('click'); + clickSubmit(); await nextTick(); expect(findLoadingIcon().exists()).toBe(true); diff --git a/spec/frontend/runner/components/runner_header_spec.js b/spec/frontend/runner/components/runner_header_spec.js new file mode 100644 index 00000000000..50699df3a44 --- /dev/null +++ b/spec/frontend/runner/components/runner_header_spec.js @@ -0,0 +1,93 @@ +import { GlSprintf } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { GROUP_TYPE, STATUS_ONLINE } from '~/runner/constants'; +import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +import RunnerHeader from '~/runner/components/runner_header.vue'; +import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue'; + +import { runnerData } from '../mock_data'; + +const mockRunner = runnerData.data.runner; + +describe('RunnerHeader', () => { + let wrapper; + + const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); + const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); + const findTimeAgo = () => wrapper.findComponent(TimeAgo); + + const createComponent = ({ runner = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerHeader, { + propsData: { + runner: { + ...mockRunner, + ...runner, + }, + }, + stubs: { + GlSprintf, + TimeAgo, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the runner status', () => { + createComponent({ + mountFn: mount, + runner: { + status: STATUS_ONLINE, + }, + }); + + expect(findRunnerStatusBadge().text()).toContain(`online`); + }); + + it('displays the runner type', () => { + createComponent({ + mountFn: mount, + runner: { + runnerType: GROUP_TYPE, + }, + }); + + expect(findRunnerTypeBadge().text()).toContain(`group`); + }); + + it('displays the runner id', () => { + createComponent({ + runner: { + id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + }, + }); + + expect(wrapper.text()).toContain(`Runner #99`); + }); + + it('displays the runner creation time', () => { + createComponent(); + + expect(wrapper.text()).toMatch(/created .+/); + expect(findTimeAgo().props('time')).toBe(mockRunner.createdAt); + }); + + it('does not display runner creation time if createdAt missing', () => { + createComponent({ + runner: { + id: convertToGraphQLId(TYPE_CI_RUNNER, 99), + createdAt: null, + }, + }); + + expect(wrapper.text()).toContain(`Runner #99`); + expect(wrapper.text()).not.toMatch(/created .+/); + expect(findTimeAgo().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 5a14fa5a2d5..452430b7237 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -69,7 +69,9 @@ describe('RunnerList', () => { const { id, description, version, ipAddress, shortSha } = mockRunners[0]; // Badges - expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('not connected paused'); + expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText( + 'never contacted paused', + ); // Runner summary expect(findCell({ fieldKey: 'summary' }).text()).toContain( diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js index a19515d6ed2..c470c6bb989 100644 --- a/spec/frontend/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/runner/components/runner_status_badge_spec.js @@ -6,7 +6,6 @@ import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE, - STATUS_NOT_CONNECTED, STATUS_NEVER_CONTACTED, } from '~/runner/constants'; @@ -50,20 +49,7 @@ describe('RunnerTypeBadge', () => { expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); }); - it('renders not connected state', () => { - createComponent({ - runner: { - contactedAt: null, - status: STATUS_NOT_CONNECTED, - }, - }); - - expect(wrapper.text()).toBe('not connected'); - expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never connected'); - }); - - it('renders never contacted state as not connected, for backwards compatibility', () => { + it('renders never contacted state', () => { createComponent({ runner: { contactedAt: null, @@ -71,9 +57,9 @@ describe('RunnerTypeBadge', () => { }, }); - expect(wrapper.text()).toBe('not connected'); + expect(wrapper.text()).toBe('never contacted'); expect(findBadge().props('variant')).toBe('muted'); - expect(getTooltip().value).toMatch('This runner has never connected'); + expect(getTooltip().value).toMatch('This runner has never contacted'); }); it('renders offline state', () => { diff --git a/spec/frontend/runner/components/runner_type_alert_spec.js b/spec/frontend/runner/components/runner_type_alert_spec.js deleted file mode 100644 index 4023c75c9a8..00000000000 --- a/spec/frontend/runner/components/runner_type_alert_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { GlAlert, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerTypeAlert from '~/runner/components/runner_type_alert.vue'; -import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; - -describe('RunnerTypeAlert', () => { - let wrapper; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findLink = () => wrapper.findComponent(GlLink); - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(RunnerTypeAlert, { - propsData: { - type: INSTANCE_TYPE, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe.each` - type | exampleText | anchor - ${INSTANCE_TYPE} | ${'This runner is available to all groups and projects'} | ${'#shared-runners'} - ${GROUP_TYPE} | ${'This runner is available to all projects and subgroups in a group'} | ${'#group-runners'} - ${PROJECT_TYPE} | ${'This runner is associated with one or more projects'} | ${'#specific-runners'} - `('When it is an $type level runner', ({ type, exampleText, anchor }) => { - beforeEach(() => { - createComponent({ props: { type } }); - }); - - it('Describes runner type', () => { - expect(wrapper.text()).toMatch(exampleText); - }); - - it(`Shows an "info" variant`, () => { - expect(findAlert().props('variant')).toBe('info'); - }); - - it(`Links to anchor "${anchor}"`, () => { - expect(findLink().attributes('href')).toBe(`/help/ci/runners/runners_scope${anchor}`); - }); - }); - - describe('When runner type is not correct', () => { - it('Does not render content when type is missing', () => { - createComponent({ props: { type: undefined } }); - - expect(wrapper.html()).toBe(''); - }); - - it('Validation fails for an incorrect type', () => { - expect(() => { - createComponent({ props: { type: 'NOT_A_TYPE' } }); - }).toThrow(); - }); - }); -}); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 0e0844a785b..ebb2e67d1e2 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -5,7 +5,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import RunnerUpdateForm from '~/runner/components/runner_update_form.vue'; import { INSTANCE_TYPE, @@ -79,9 +79,9 @@ describe('RunnerUpdateForm', () => { input: expect.objectContaining(submittedRunner), }); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: expect.stringContaining('saved'), - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); expect(findSubmitDisabledAttr()).toBeUndefined(); @@ -127,7 +127,7 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); // Some fields are not submitted - const { ipAddress, runnerType, ...submitted } = mockRunner; + const { ipAddress, runnerType, createdAt, status, ...submitted } = mockRunner; expectToHaveSubmittedRunnerContaining(submitted); }); @@ -238,7 +238,7 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: `Network error: ${mockErrorMsg}`, }); expect(captureException).toHaveBeenCalledWith({ @@ -262,7 +262,7 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); - expect(createFlash).toHaveBeenLastCalledWith({ + expect(createAlert).toHaveBeenLastCalledWith({ message: mockErrorMsg, }); expect(captureException).not.toHaveBeenCalled(); diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js index 89c06ba2df4..52557ff716d 100644 --- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import TagToken, { TAG_SUGGESTIONS_PATH } from '~/runner/components/search_tokens/tag_token.vue'; @@ -168,8 +168,8 @@ describe('TagToken', () => { }); it('error is shown', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ message: expect.any(String) }); + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: expect.any(String) }); }); }); diff --git a/spec/frontend/runner/components/stat/runner_online_stat_spec.js b/spec/frontend/runner/components/stat/runner_online_stat_spec.js deleted file mode 100644 index 18f865aa22c..00000000000 --- a/spec/frontend/runner/components/stat/runner_online_stat_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { GlSingleStat } from '@gitlab/ui/dist/charts'; -import { shallowMount, mount } from '@vue/test-utils'; -import RunnerOnlineBadge from '~/runner/components/stat/runner_online_stat.vue'; - -describe('RunnerOnlineBadge', () => { - let wrapper; - - const findSingleStat = () => wrapper.findComponent(GlSingleStat); - - const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { - wrapper = mountFn(RunnerOnlineBadge, { - propsData: { - value: '99', - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('Uses a success appearance', () => { - createComponent({}, shallowMount); - - expect(findSingleStat().props('variant')).toBe('success'); - }); - - it('Renders a value', () => { - createComponent({}, mount); - - expect(wrapper.text()).toMatch(new RegExp(`Online Runners 99\\s+online`)); - }); -}); diff --git a/spec/frontend/runner/components/stat/runner_stats_spec.js b/spec/frontend/runner/components/stat/runner_stats_spec.js new file mode 100644 index 00000000000..68db8621ef0 --- /dev/null +++ b/spec/frontend/runner/components/stat/runner_stats_spec.js @@ -0,0 +1,46 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerStats from '~/runner/components/stat/runner_stats.vue'; +import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants'; + +describe('RunnerStats', () => { + let wrapper; + + const findRunnerStatusStatAt = (i) => wrapper.findAllComponents(RunnerStatusStat).at(i); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + wrapper = mountFn(RunnerStats, { + propsData: { + onlineRunnersCount: 3, + offlineRunnersCount: 2, + staleRunnersCount: 1, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays all the stats', () => { + createComponent({ mountFn: mount }); + + const stats = wrapper.text(); + + expect(stats).toMatch('Online runners 3'); + expect(stats).toMatch('Offline runners 2'); + expect(stats).toMatch('Stale runners 1'); + }); + + it.each` + i | status + ${0} | ${STATUS_ONLINE} + ${1} | ${STATUS_OFFLINE} + ${2} | ${STATUS_STALE} + `('Displays status types at index $i', ({ i, status }) => { + createComponent(); + + expect(findRunnerStatusStatAt(i).props('status')).toBe(status); + }); +}); diff --git a/spec/frontend/runner/components/stat/runner_status_stat_spec.js b/spec/frontend/runner/components/stat/runner_status_stat_spec.js new file mode 100644 index 00000000000..3218272eac7 --- /dev/null +++ b/spec/frontend/runner/components/stat/runner_status_stat_spec.js @@ -0,0 +1,67 @@ +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { shallowMount, mount } from '@vue/test-utils'; +import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue'; +import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants'; + +describe('RunnerStatusStat', () => { + let wrapper; + + const findSingleStat = () => wrapper.findComponent(GlSingleStat); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => { + wrapper = mountFn(RunnerStatusStat, { + propsData: { + status: STATUS_ONLINE, + value: 99, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + status | variant | title | badge + ${STATUS_ONLINE} | ${'success'} | ${'Online runners'} | ${'online'} + ${STATUS_OFFLINE} | ${'muted'} | ${'Offline runners'} | ${'offline'} + ${STATUS_STALE} | ${'warning'} | ${'Stale runners'} | ${'stale'} + `('Renders a stat for status "$status"', ({ status, variant, title, badge }) => { + beforeEach(() => { + createComponent({ props: { status } }, mount); + }); + + it('Renders text', () => { + expect(wrapper.text()).toMatch(new RegExp(`${title} 99\\s+${badge}`)); + }); + + it(`Uses variant ${variant}`, () => { + expect(findSingleStat().props('variant')).toBe(variant); + }); + }); + + it('Formats stat number', () => { + createComponent({ props: { value: 1000 } }, mount); + + expect(wrapper.text()).toMatch('Online runners 1,000'); + }); + + it('Shows a null result', () => { + createComponent({ props: { value: null } }, mount); + + expect(wrapper.text()).toMatch('Online runners -'); + }); + + it('Shows an undefined result', () => { + createComponent({ props: { value: undefined } }, mount); + + expect(wrapper.text()).toMatch('Online runners -'); + }); + + it('Shows result for an unknown status', () => { + createComponent({ props: { status: 'UNKNOWN' } }, mount); + + expect(wrapper.text()).toMatch('Runners 99'); + }); +}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 4451100de19..034b7848f35 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -6,12 +6,13 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; +import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; @@ -26,10 +27,11 @@ import { RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; +import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql'; import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; import { captureException } from '~/runner/sentry_utils'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data'; +import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -48,7 +50,9 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('GroupRunnersApp', () => { let wrapper; let mockGroupRunnersQuery; + let mockGroupRunnersCountQuery; + const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -59,7 +63,10 @@ describe('GroupRunnersApp', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { - const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]]; + const handlers = [ + [getGroupRunnersQuery, mockGroupRunnersQuery], + [getGroupRunnersCountQuery, mockGroupRunnersCountQuery], + ]; wrapper = mountFn(GroupRunnersApp, { localVue, @@ -77,11 +84,24 @@ describe('GroupRunnersApp', () => { setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`); mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); + mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData); createComponent(); await waitForPromises(); }); + it('shows total runner counts', async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + const stats = findRunnerStats().text(); + + expect(stats).toMatch('Online runners 2'); + expect(stats).toMatch('Offline runners 2'); + expect(stats).toMatch('Stale runners 2'); + }); + it('shows the runner setup instructions', () => { expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); @@ -129,28 +149,6 @@ describe('GroupRunnersApp', () => { ); }); - describe('shows the active runner count', () => { - const expectedOnlineCount = (count) => new RegExp(`Online Runners ${count}`); - - it('with a regular value', () => { - createComponent({ mountFn: mount }); - - expect(wrapper.text()).toMatch(expectedOnlineCount(mockGroupRunnersLimitedCount)); - }); - - it('at the limit', () => { - createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount }); - - expect(wrapper.text()).toMatch(expectedOnlineCount('1,000')); - }); - - it('over the limit', () => { - createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount }); - - expect(wrapper.text()).toMatch(expectedOnlineCount('1,000\\+')); - }); - }); - describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); @@ -236,7 +234,7 @@ describe('GroupRunnersApp', () => { }); it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index b8d0f1273c7..9c430e205ea 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -2,17 +2,21 @@ // Admin queries import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql.json'; +import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.query.graphql.json'; import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json'; import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json'; // Group queries import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json'; +import groupRunnersCountData from 'test_fixtures/graphql/runner/get_group_runners_count.query.graphql.json'; import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json'; export { runnerData, + runnersCountData, runnersDataPaginated, runnersData, groupRunnersData, + groupRunnersCountData, groupRunnersDataPaginated, }; diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js index 0fc7917663e..aff1ec882bb 100644 --- a/spec/frontend/runner/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_search_utils_spec.js @@ -1,6 +1,7 @@ import { RUNNER_PAGE_SIZE } from '~/runner/constants'; import { searchValidator, + updateOutdatedUrl, fromUrlQueryToSearch, fromSearchToUrl, fromSearchToVariables, @@ -190,6 +191,23 @@ describe('search_params.js', () => { }); }); + describe('updateOutdatedUrl', () => { + it('returns null for urls that do not need updating', () => { + expect(updateOutdatedUrl('http://test.host/')).toBe(null); + expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null); + }); + + it('returns updated url for updating NOT_CONNECTED to NEVER_CONTACTED', () => { + expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED')).toBe( + 'http://test.host/admin/runners?status[]=NEVER_CONTACTED', + ); + + expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED&a=b')).toBe( + 'http://test.host/admin/runners?status[]=NEVER_CONTACTED&a=b', + ); + }); + }); + describe('fromUrlQueryToSearch', () => { examples.forEach(({ name, urlQuery, search }) => { it(`Converts ${name} to a search object`, () => { diff --git a/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js b/spec/frontend/runner/runner_update_form_utils_spec.js index 510b4e604ac..a633aee92f7 100644 --- a/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js +++ b/spec/frontend/runner/runner_update_form_utils_spec.js @@ -1,8 +1,5 @@ import { ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants'; -import { - modelToUpdateMutationVariables, - runnerToModel, -} from '~/runner/runner_details/runner_update_form_utils'; +import { modelToUpdateMutationVariables, runnerToModel } from '~/runner/runner_update_form_utils'; const mockId = 'gid://gitlab/Ci::Runner/1'; const mockDescription = 'Runner Desc.'; @@ -23,7 +20,7 @@ const mockModel = { tagList: 'tag-1, tag-2', }; -describe('~/runner/runner_details/runner_update_form_utils', () => { +describe('~/runner/runner_update_form_utils', () => { describe('runnerToModel', () => { it('collects all model data', () => { expect(runnerToModel(mockRunner)).toEqual(mockModel); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index b21cf5c6b79..de1cefa9e9d 100644 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -133,6 +133,8 @@ describe('Global Search Searchable Dropdown', () => { describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => { beforeEach(() => { createComponent({}, { frequentItems }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchText }); }); @@ -202,6 +204,8 @@ describe('Global Search Searchable Dropdown', () => { describe('not for the first time', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ hasBeenOpened: true }); findGlDropdown().vm.$emit('show'); }); diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 0a2b18caf25..cbdf7f53913 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlTab } from '@gitlab/ui'; +import { GlTab, GlTabs } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; @@ -77,6 +77,7 @@ describe('App component', () => { const findMainHeading = () => wrapper.find('h1'); const findTab = () => wrapper.findComponent(GlTab); const findTabs = () => wrapper.findAllComponents(GlTab); + const findGlTabs = () => wrapper.findComponent(GlTabs); const findByTestId = (id) => wrapper.findByTestId(id); const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); const findTrainingProviderList = () => wrapper.findComponent(TrainingProviderList); @@ -154,6 +155,14 @@ describe('App component', () => { expect(findTab().exists()).toBe(true); }); + it('passes the `sync-active-tab-with-query-params` prop', () => { + expect(findGlTabs().props('syncActiveTabWithQueryParams')).toBe(true); + }); + + it('lazy loads each tab', () => { + expect(findGlTabs().attributes('lazy')).not.toBe(undefined); + }); + it('renders correct amount of tabs', () => { expect(findTabs()).toHaveLength(expectedTabs.length); }); @@ -161,6 +170,10 @@ describe('App component', () => { it.each(expectedTabs)('renders the %s tab', (tabName) => { expect(findByTestId(`${tabName}-tab`).exists()).toBe(true); }); + + it.each(expectedTabs)('has the %s query-param-value', (tabName) => { + expect(findByTestId(`${tabName}-tab`).props('queryParamValue')).toBe(tabName); + }); }); it('renders right amount of feature cards for given props with correct props', () => { @@ -182,10 +195,6 @@ describe('App component', () => { expect(findComplianceViewHistoryLink().exists()).toBe(false); expect(findSecurityViewHistoryLink().exists()).toBe(false); }); - - it('renders TrainingProviderList component', () => { - expect(findTrainingProviderList().exists()).toBe(true); - }); }); describe('Manage via MR Error Alert', () => { @@ -432,6 +441,25 @@ describe('App component', () => { }); }); + describe('Vulnerability management', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + }); + + it('renders TrainingProviderList component', () => { + expect(findTrainingProviderList().exists()).toBe(true); + }); + + it('renders security training description', () => { + const vulnerabilityManagementTab = wrapper.findByTestId('vulnerability-management-tab'); + + expect(vulnerabilityManagementTab.text()).toContain(i18n.securityTrainingDescription); + }); + }); + describe('when secureVulnerabilityTraining feature flag is disabled', () => { beforeEach(() => { createComponent({ diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 60cc36a634c..578248e696f 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,87 +1,192 @@ -import { GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; +import configureSecurityTrainingProvidersMutation from '~/security_configuration/graphql/configure_security_training_providers.mutation.graphql'; import waitForPromises from 'helpers/wait_for_promises'; -import { securityTrainingProviders, mockResolvers } from '../mock_data'; +import { + securityTrainingProviders, + createMockResolvers, + testProjectPath, + textProviderIds, +} from '../mock_data'; Vue.use(VueApollo); describe('TrainingProviderList component', () => { let wrapper; - let mockApollo; - let mockSecurityTrainingProvidersData; + let apolloProvider; - const createComponent = () => { - mockApollo = createMockApollo([], mockResolvers); + const createApolloProvider = ({ resolvers } = {}) => { + apolloProvider = createMockApollo([], createMockResolvers({ resolvers })); + }; + const createComponent = () => { wrapper = shallowMount(TrainingProviderList, { - apolloProvider: mockApollo, + provide: { + projectPath: testProjectPath, + }, + apolloProvider, }); }; const waitForQueryToBeLoaded = () => waitForPromises(); + const waitForMutationToBeLoaded = waitForQueryToBeLoaded; const findCards = () => wrapper.findAllComponents(GlCard); const findLinks = () => wrapper.findAllComponents(GlLink); const findToggles = () => wrapper.findAllComponents(GlToggle); + const findFirstToggle = () => findToggles().at(0); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findErrorAlert = () => wrapper.findComponent(GlAlert); - beforeEach(() => { - mockSecurityTrainingProvidersData = jest.fn(); - mockSecurityTrainingProvidersData.mockResolvedValue(securityTrainingProviders); - - createComponent(); - }); + const toggleFirstProvider = () => findFirstToggle().vm.$emit('change'); afterEach(() => { wrapper.destroy(); - mockApollo = null; + apolloProvider = null; }); - describe('when loading', () => { - it('shows the loader', () => { - expect(findLoader().exists()).toBe(true); + describe('with a successful response', () => { + beforeEach(() => { + createApolloProvider(); + createComponent(); }); - it('does not show the cards', () => { - expect(findCards().exists()).toBe(false); + describe('when loading', () => { + it('shows the loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('does not show the cards', () => { + expect(findCards().exists()).toBe(false); + }); }); - }); - describe('basic structure', () => { - beforeEach(async () => { - await waitForQueryToBeLoaded(); + describe('basic structure', () => { + beforeEach(async () => { + await waitForQueryToBeLoaded(); + }); + + it('renders correct amount of cards', () => { + expect(findCards()).toHaveLength(securityTrainingProviders.length); + }); + + securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => { + it(`shows the name for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(name); + }); + + it(`shows the description for card ${index}`, () => { + expect(findCards().at(index).text()).toContain(description); + }); + + it(`shows the learn more link for card ${index}`, () => { + expect(findLinks().at(index).attributes()).toEqual({ + target: '_blank', + href: url, + }); + }); + + it(`shows the toggle with the correct value for card ${index}`, () => { + expect(findToggles().at(index).props('value')).toEqual(isEnabled); + }); + + it('does not show loader when query is populated', () => { + expect(findLoader().exists()).toBe(false); + }); + }); }); - it('renders correct amount of cards', () => { - expect(findCards()).toHaveLength(securityTrainingProviders.length); + describe('storing training provider settings', () => { + beforeEach(async () => { + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + + await waitForMutationToBeLoaded(); + + toggleFirstProvider(); + }); + + it.each` + loading | wait | desc + ${true} | ${false} | ${'enables loading of GlToggle when mutation is called'} + ${false} | ${true} | ${'disables loading of GlToggle when mutation is complete'} + `('$desc', async ({ loading, wait }) => { + if (wait) { + await waitForMutationToBeLoaded(); + } + expect(findFirstToggle().props('isLoading')).toBe(loading); + }); + + it('calls mutation when toggle is changed', () => { + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + mutation: configureSecurityTrainingProvidersMutation, + variables: { input: { enabledProviders: textProviderIds, fullPath: testProjectPath } }, + }), + ); + }); }); + }); + + describe('with errors', () => { + const expectErrorAlertToExist = () => { + expect(findErrorAlert().props()).toMatchObject({ + dismissible: false, + variant: 'danger', + }); + }; + + describe('when fetching training providers', () => { + beforeEach(async () => { + createApolloProvider({ + resolvers: { + Query: { + securityTrainingProviders: jest.fn().mockReturnValue(new Error()), + }, + }, + }); + createComponent(); - securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => { - it(`shows the name for card ${index}`, () => { - expect(findCards().at(index).text()).toContain(name); + await waitForQueryToBeLoaded(); }); - it(`shows the description for card ${index}`, () => { - expect(findCards().at(index).text()).toContain(description); + it('shows an non-dismissible error alert', () => { + expectErrorAlertToExist(); }); - it(`shows the learn more link for card ${index}`, () => { - expect(findLinks().at(index).attributes()).toEqual({ - target: '_blank', - href: url, + it('shows an error description', () => { + expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.providerQueryErrorMessage); + }); + }); + + describe('when storing training provider configurations', () => { + beforeEach(async () => { + createApolloProvider({ + resolvers: { + Mutation: { + configureSecurityTrainingProviders: () => ({ + errors: ['something went wrong!'], + securityTrainingProviders: [], + }), + }, + }, }); + createComponent(); + + await waitForQueryToBeLoaded(); + toggleFirstProvider(); + await waitForMutationToBeLoaded(); }); - it(`shows the toggle with the correct value for card ${index}`, () => { - expect(findToggles().at(index).props('value')).toEqual(isEnabled); + it('shows an non-dismissible error alert', () => { + expectErrorAlertToExist(); }); - it('does not show loader when query is populated', () => { - expect(findLoader().exists()).toBe(false); + it('shows an error description', () => { + expect(findErrorAlert().text()).toBe(TrainingProviderList.i18n.configMutationErrorMessage); }); }); }); diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index cdb859c3800..37ecce3886d 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -1,16 +1,20 @@ +export const testProjectPath = 'foo/bar'; + +export const textProviderIds = [101, 102]; + export const securityTrainingProviders = [ { - id: 101, - name: 'Kontra', - description: 'Interactive developer security education.', - url: 'https://application.security/', + id: textProviderIds[0], + name: 'Vendor Name 1', + description: 'Interactive developer security education', + url: 'https://www.example.org/security/training', isEnabled: false, }, { - id: 102, - name: 'SecureCodeWarrior', + id: textProviderIds[1], + name: 'Vendor Name 2', description: 'Security training with guide and learning pathways.', - url: 'https://www.securecodewarrior.com/', + url: 'https://www.vendornametwo.com/', isEnabled: true, }, ]; @@ -21,10 +25,15 @@ export const securityTrainingProvidersResponse = { }, }; -export const mockResolvers = { +const defaultMockResolvers = { Query: { securityTrainingProviders() { return securityTrainingProviders; }, }, }; + +export const createMockResolvers = ({ resolvers: customMockResolvers = {} } = {}) => ({ + ...defaultMockResolvers, + ...customMockResolvers, +}); diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index c25a8d4bb92..350055cb935 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EmptyStateComponent should render content 1`] = ` -"<section class=\\"row empty-state text-center\\"> - <div class=\\"col-12\\"> +"<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\"> + <div class=\\"gl-max-w-full\\"> <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full\\"></div> </div> - <div class=\\"col-12\\"> - <div class=\\"text-content gl-mx-auto gl-my-0 gl-p-5\\"> + <div class=\\"gl-max-w-full gl-m-auto\\"> + <div class=\\"gl-mx-auto gl-my-0 gl-p-5\\"> <h1 class=\\"gl-font-size-h-display gl-line-height-36 h4\\"> Getting started with serverless </h1> diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index d7261784edc..0c6ed998747 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -110,14 +110,23 @@ describe('SetStatusModalWrapper', () => { }); describe('improvedEmojiPicker is true', () => { + const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); + beforeEach(async () => { await initEmojiMock(); wrapper = createComponent({}, true); return initModal(); }); + it('renders emoji picker dropdown with custom positioning', () => { + expect(getEmojiPicker().props()).toMatchObject({ + right: false, + boundary: 'viewport', + }); + }); + it('sets emojiTag when clicking in emoji picker', async () => { - await wrapper.findComponent(EmojiPicker).vm.$emit('click', 'thumbsup'); + await getEmojiPicker().vm.$emit('click', 'thumbsup'); expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"'); }); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 13887f28d22..d0792fa7b73 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -48,12 +48,16 @@ describe('UncollapsedReviewerList component', () => { }); it('renders re-request loading icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 1: 'loading' } }); expect(wrapper.find('[data-testid="re-request-button"]').props('loading')).toBe(true); }); it('renders re-request success icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 1: 'success' } }); expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); @@ -98,6 +102,8 @@ describe('UncollapsedReviewerList component', () => { }); it('renders re-request loading icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 2: 'loading' } }); expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(2); @@ -107,6 +113,8 @@ describe('UncollapsedReviewerList component', () => { }); it('renders re-request success icon', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ loadingStates: { 2: 'success' } }); expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(1); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index 1210f7c9531..94cdbe7f2ef 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -85,6 +85,8 @@ describe('Participants', () => { numberOfLessParticipants, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: false, }); @@ -101,6 +103,8 @@ describe('Participants', () => { numberOfLessParticipants: 2, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: true, }); @@ -129,6 +133,8 @@ describe('Participants', () => { numberOfLessParticipants: 2, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: false, }); @@ -145,6 +151,8 @@ describe('Participants', () => { numberOfLessParticipants: 2, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isShowingMoreParticipants: true, }); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 40bc6fe6aa5..c193bb08543 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -90,6 +90,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = /> <!----> + + <!----> </div> </div> </div> diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index b92c1907980..172089f9ee6 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -157,6 +157,8 @@ describe('Blob Embeddable', () => { }); // mimic apollo's update + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ blobContent: wrapper.vm.onContentUpdate(apolloData), }); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 2d5e0cfd615..daa9d6345b0 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -242,6 +242,8 @@ describe('Snippet header component', () => { // TODO: we should avoid `wrapper.setData` since they // are component internals. Let's use the apollo mock helpers // in a follow-up. + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ canCreateSnippet: true }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index 1d6245e9dbb..a833fd9ff9e 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -132,6 +132,8 @@ describe('~/static_site_editor/components/edit_area.vue', () => { describe('when the mode changes', () => { const setInitialMode = (mode) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ editorMode: mode }); }; @@ -207,6 +209,8 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }); it('syncs matter changes to content in markdown mode', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ editorMode: EDITOR_TYPES.markdown }); const newSettings = { title: 'test' }; diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js index 86ae016987d..c8c9f45618d 100644 --- a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js @@ -48,6 +48,8 @@ describe('Add Image Modal', () => { const file = { name: 'some_file.png' }; wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); findModal().vm.$emit('ok', { preventDefault }); @@ -60,6 +62,8 @@ describe('Add Image Modal', () => { it('emits an addImage event when a valid URL is specified', () => { const preventDefault = jest.fn(); const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); findModal().vm.$emit('ok', { preventDefault }); diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index 6a2b89a8dcf..ddc96ed6832 100644 --- a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -13,7 +13,7 @@ const normalParagraphNode = buildMockParagraphNode( 'This is just normal paragraph. It has multiple sentences.', ); const identifierParagraphNode = buildMockParagraphNode( - `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, + `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example.org`, ); describe('rich_content_editor/renderers_render_identifier_paragraph', () => { diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index 9d28e8ce294..fbe55306f37 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -293,6 +293,8 @@ describe('StatesTableActions', () => { describe('when state name is present', () => { beforeEach(async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax await wrapper.setData({ removeConfirmText: defaultProps.state.name }); findRemoveModal().vm.$emit('ok'); diff --git a/spec/frontend/tracking/tracking_initialization_spec.js b/spec/frontend/tracking/tracking_initialization_spec.js index 2b70aacc4cb..f1628ad9793 100644 --- a/spec/frontend/tracking/tracking_initialization_spec.js +++ b/spec/frontend/tracking/tracking_initialization_spec.js @@ -81,7 +81,8 @@ describe('Tracking', () => { it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [standardContext]); + expect(snowplowSpy).toHaveBeenCalledWith('setDocumentTitle', 'GitLab'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); @@ -130,7 +131,7 @@ describe('Tracking', () => { it('includes those contexts alongside the standard context', () => { initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', 'GitLab', [ standardContext, ...experimentContexts, ]); diff --git a/spec/frontend/version_check_image_spec.js b/spec/frontend/version_check_image_spec.js deleted file mode 100644 index 13bd104a91c..00000000000 --- a/spec/frontend/version_check_image_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import $ from 'jquery'; -import ClassSpecHelper from 'helpers/class_spec_helper'; -import VersionCheckImage from '~/version_check_image'; - -describe('VersionCheckImage', () => { - let testContext; - - beforeEach(() => { - testContext = {}; - }); - - describe('bindErrorEvent', () => { - ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); - - beforeEach(() => { - testContext.imageElement = $('<div></div>'); - }); - - it('registers an error event', () => { - jest.spyOn($.prototype, 'on').mockImplementation(() => {}); - // eslint-disable-next-line func-names - jest.spyOn($.prototype, 'off').mockImplementation(function () { - return this; - }); - - VersionCheckImage.bindErrorEvent(testContext.imageElement); - - expect($.prototype.off).toHaveBeenCalledWith('error'); - expect($.prototype.on).toHaveBeenCalledWith('error', expect.any(Function)); - }); - - it('hides the imageElement on error', () => { - jest.spyOn($.prototype, 'hide').mockImplementation(() => {}); - - VersionCheckImage.bindErrorEvent(testContext.imageElement); - - testContext.imageElement.trigger('error'); - - expect($.prototype.hide).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index af6624a6c43..36850e623c7 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -101,6 +101,8 @@ describe('MRWidget approvals', () => { }); it('shows loading message', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ fetchingApprovals: true }); return tick().then(() => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index a09269e869c..5a1f17573d4 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -153,6 +153,8 @@ describe('MRWidgetHeader', () => { gitpodEnabled: true, showGitpodButton: true, gitpodUrl: 'http://gitpod.localhost', + userPreferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled', + userProfileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true', }; it('renders checkout branch button with modal trigger', () => { @@ -208,6 +210,8 @@ describe('MRWidgetHeader', () => { gitpodEnabled: true, showGitpodButton: true, gitpodUrl: 'http://gitpod.localhost', + userPreferencesGitpodPath: mrDefaultOptions.userPreferencesGitpodPath, + userProfileEnableGitpodPath: mrDefaultOptions.userProfileEnableGitpodPath, webIdeUrl, }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js index d3221cc2fc7..27604868b3e 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js @@ -2,10 +2,15 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import ActionsButton from '~/vue_shared/components/actions_button.vue'; +import { + REBASE_BUTTON_KEY, + REBASE_WITHOUT_CI_BUTTON_KEY, +} from '~/vue_merge_request_widget/constants'; let wrapper; -function factory(propsData, mergeRequestWidgetGraphql) { +function createWrapper(propsData, mergeRequestWidgetGraphql, rebaseWithoutCiUi) { wrapper = shallowMount(WidgetRebase, { propsData, data() { @@ -19,7 +24,7 @@ function factory(propsData, mergeRequestWidgetGraphql) { }, }; }, - provide: { glFeatures: { mergeRequestWidgetGraphql } }, + provide: { glFeatures: { mergeRequestWidgetGraphql, rebaseWithoutCiUi } }, mocks: { $apollo: { queries: { @@ -31,8 +36,10 @@ function factory(propsData, mergeRequestWidgetGraphql) { } describe('Merge request widget rebase component', () => { - const findRebaseMessageEl = () => wrapper.find('[data-testid="rebase-message"]'); - const findRebaseMessageElText = () => findRebaseMessageEl().text(); + const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]'); + const findRebaseMessageText = () => findRebaseMessage().text(); + const findRebaseButtonActions = () => wrapper.find(ActionsButton); + const findStandardRebaseButton = () => wrapper.find('[data-testid="standard-rebase-button"]'); afterEach(() => { wrapper.destroy(); @@ -40,10 +47,10 @@ describe('Merge request widget rebase component', () => { }); [true, false].forEach((mergeRequestWidgetGraphql) => { - describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'dislabed'}`, () => { - describe('While rebasing', () => { + describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'disabled'}`, () => { + describe('while rebasing', () => { it('should show progress message', () => { - factory( + createWrapper( { mr: { rebaseInProgress: true }, service: {}, @@ -51,24 +58,30 @@ describe('Merge request widget rebase component', () => { mergeRequestWidgetGraphql, ); - expect(findRebaseMessageElText()).toContain('Rebase in progress'); + expect(findRebaseMessageText()).toContain('Rebase in progress'); }); }); - describe('With permissions', () => { - it('it should render rebase button and warning message', () => { - factory( + describe('with permissions', () => { + const rebaseMock = jest.fn().mockResolvedValue(); + const pollMock = jest.fn().mockResolvedValue({}); + + it('renders the warning message', () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: true, }, - service: {}, + service: { + rebase: rebaseMock, + poll: pollMock, + }, }, mergeRequestWidgetGraphql, ); - const text = findRebaseMessageElText(); + const text = findRebaseMessageText(); expect(text).toContain('Merge blocked'); expect(text.replace(/\s\s+/g, ' ')).toContain( @@ -76,73 +89,195 @@ describe('Merge request widget rebase component', () => { ); }); - it('it should render error message when it fails', async () => { - factory( + it('renders an error message when rebasing has failed', async () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: true, }, - service: {}, + service: { + rebase: rebaseMock, + poll: pollMock, + }, }, mergeRequestWidgetGraphql, ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ rebasingError: 'Something went wrong!' }); await nextTick(); - expect(findRebaseMessageElText()).toContain('Something went wrong!'); + expect(findRebaseMessageText()).toContain('Something went wrong!'); + }); + + describe('Rebase button with flag rebaseWithoutCiUi', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + { rebaseWithoutCiUi: true }, + ); + }); + + it('rebase button with actions is rendered', () => { + expect(findRebaseButtonActions().exists()).toBe(true); + expect(findStandardRebaseButton().exists()).toBe(false); + }); + + it('has rebase and rebase without CI actions', () => { + const actionNames = findRebaseButtonActions() + .props('actions') + .map((action) => action.key); + + expect(actionNames).toStrictEqual([REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY]); + }); + + it('defaults to rebase action', () => { + expect(findRebaseButtonActions().props('selectedKey')).toStrictEqual(REBASE_BUTTON_KEY); + }); + + it('starts the rebase when clicking', async () => { + // ActionButtons use the actions props instead of emitting + // a click event, therefore simulating the behavior here: + findRebaseButtonActions() + .props('actions') + .find((x) => x.key === REBASE_BUTTON_KEY) + .handle(); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); + }); + + it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => { + // ActionButtons use the actions props instead of emitting + // a click event, therefore simulating the behavior here: + findRebaseButtonActions() + .props('actions') + .find((x) => x.key === REBASE_WITHOUT_CI_BUTTON_KEY) + .handle(); + + await nextTick(); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true }); + }); + }); + + describe('Rebase button with rebaseWithoutCiUI flag disabled', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: { + rebase: rebaseMock, + poll: pollMock, + }, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('standard rebase button is rendered', () => { + expect(findStandardRebaseButton().exists()).toBe(true); + expect(findRebaseButtonActions().exists()).toBe(false); + }); + + it('calls rebase method with skip_ci false', () => { + findStandardRebaseButton().vm.$emit('click'); + + expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false }); + }); }); }); - describe('Without permissions', () => { - it('should render a message explaining user does not have permissions', () => { - factory( + describe('without permissions', () => { + const exampleTargetBranch = 'fake-branch-to-test-with'; + + describe('UI text', () => { + beforeEach(() => { + createWrapper( + { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: false, + targetBranch: exampleTargetBranch, + }, + service: {}, + }, + mergeRequestWidgetGraphql, + ); + }); + + it('renders a message explaining user does not have permissions', () => { + const text = findRebaseMessageText(); + + expect(text).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', + ); + expect(text).toContain('the source branch must be rebased'); + }); + + it('renders the correct target branch name', () => { + const elem = findRebaseMessage(); + + expect(elem.text()).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', + ); + }); + }); + + it('does not render the rebase actions button with rebaseWithoutCiUI flag enabled', () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: false, - targetBranch: 'foo', + targetBranch: exampleTargetBranch, }, service: {}, }, mergeRequestWidgetGraphql, + { rebaseWithoutCiUi: true }, ); - const text = findRebaseMessageElText(); - - expect(text).toContain( - 'Merge blocked: the source branch must be rebased onto the target branch.', - ); - expect(text).toContain('the source branch must be rebased'); + expect(findRebaseButtonActions().exists()).toBe(false); }); - it('should render the correct target branch name', () => { - const targetBranch = 'fake-branch-to-test-with'; - factory( + it('does not render the standard rebase button with rebaseWithoutCiUI flag disabled', () => { + createWrapper( { mr: { rebaseInProgress: false, canPushToSourceBranch: false, - targetBranch, + targetBranch: exampleTargetBranch, }, service: {}, }, mergeRequestWidgetGraphql, ); - const elem = findRebaseMessageEl(); - - expect(elem.text()).toContain( - `Merge blocked: the source branch must be rebased onto the target branch.`, - ); + expect(findStandardRebaseButton().exists()).toBe(false); }); }); describe('methods', () => { it('checkRebaseStatus', async () => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - factory( + createWrapper( { mr: {}, service: { diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js index bdad0bada5f..1900b53ac11 100644 --- a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js @@ -15,35 +15,12 @@ describe('Merge request widget merge checks failed state component', () => { }); it.each` - mrState | displayText - ${{ isPipelineFailed: true }} | ${'pipelineFailed'} - ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} - ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'} + mrState | displayText + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ blockingMergeRequests: { total_count: 1 } }} | ${'blockingMergeRequests'} `('display $displayText text for $mrState', ({ mrState, displayText }) => { factory({ mr: mrState }); expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]); }); - - describe('unresolved discussions', () => { - it('renders jump to button', () => { - factory({ mr: { hasMergeableDiscussionsState: true } }); - - expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true); - }); - - it('renders resolve thread button', () => { - factory({ - mr: { - hasMergeableDiscussionsState: true, - createIssueToResolveDiscussionsPath: 'https://gitlab.com', - }, - }); - - expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe( - 'https://gitlab.com', - ); - }); - }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index d0a6af9970e..52a56af454f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -253,6 +253,8 @@ describe('MRWidgetAutoMergeEnabled', () => { factory({ ...defaultMrProps(), }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isCancellingAutoMerge: true, }); @@ -287,6 +289,8 @@ describe('MRWidgetAutoMergeEnabled', () => { factory({ ...defaultMrProps(), }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isRemovingSourceBranch: true, }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index 5858654e518..4d05e732f48 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -60,6 +60,8 @@ describe('Commits header component', () => { it('has a chevron-right icon', () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ expanded: false }); return wrapper.vm.$nextTick().then(() => { @@ -111,6 +113,8 @@ describe('Commits header component', () => { describe('when expanded', () => { beforeEach(() => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ expanded: true }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index 89de160b02f..ec222e66a97 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -41,6 +41,8 @@ describe('MRWidgetConflicts', () => { ); if (mergeRequestWidgetGraphql) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ userPermissions: { canMerge: propsData.mr.canMerge, diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js index 848677bf4d2..936d673768c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_missing_branch_spec.js @@ -14,6 +14,8 @@ function factory(sourceBranchRemoved, mergeRequestWidgetGraphql) { }); if (mergeRequestWidgetGraphql) { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ state: { sourceBranchExists: !sourceBranchRemoved } }); } diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 7082a19a8e7..f4ecebbb40c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -192,6 +192,8 @@ describe('ReadyToMerge', () => { it('should return "Merge in progress"', async () => { createComponent(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isMergingImmediately: true }); await Vue.nextTick(); @@ -260,6 +262,8 @@ describe('ReadyToMerge', () => { it('should return true when the vm instance is making request', async () => { createComponent({ mr: { isMergeAllowed: true } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isMakingRequest: true }); await Vue.nextTick(); @@ -287,6 +291,8 @@ describe('ReadyToMerge', () => { jest .spyOn(wrapper.vm.service, 'merge') .mockReturnValue(returnPromise('merge_when_pipeline_succeeds')); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ removeSourceBranch: false }); wrapper.vm.handleMergeButtonClick(true); @@ -691,6 +697,8 @@ describe('ReadyToMerge', () => { true, ); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: false, state: { diff --git a/spec/frontend/vue_mr_widget/components/terraform/mock_data.js b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js index ae280146c22..8e46af5dfd6 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/mock_data.js +++ b/spec/frontend/vue_mr_widget/components/terraform/mock_data.js @@ -1,6 +1,6 @@ export const invalidPlanWithName = { job_name: 'Invalid Plan', - job_path: '/path/to/ci/logs/1', + job_path: '/path/to/ci/logs/3', tf_report_error: 'api_error', }; @@ -20,12 +20,12 @@ export const validPlanWithoutName = { create: 10, update: 20, delete: 30, - job_path: '/path/to/ci/logs/1', + job_path: '/path/to/ci/logs/2', }; export const plans = { invalid_plan_one: invalidPlanWithName, - invalid_plan_two: invalidPlanWithName, + invalid_plan_two: invalidPlanWithoutName, valid_plan_one: validPlanWithName, valid_plan_two: validPlanWithoutName, }; diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js index 364f849eb4f..9048975875a 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -43,6 +43,8 @@ describe('MrWidgetTerraformConainer', () => { mockPollingApi(200, plans, {}); return mountWrapper().then(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ loading: true }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js new file mode 100644 index 00000000000..f8ea6fc23a2 --- /dev/null +++ b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js @@ -0,0 +1,178 @@ +import MockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; +import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import terraformExtension from '~/vue_merge_request_widget/extensions/terraform'; +import { + plans, + validPlanWithName, + validPlanWithoutName, + invalidPlanWithName, + invalidPlanWithoutName, +} from '../../components/terraform/mock_data'; + +describe('Terraform extension', () => { + let wrapper; + let mock; + + const endpoint = '/path/to/terraform/report.json'; + const successStatusCode = 200; + const errorStatusCode = 500; + + const findListItem = (at) => wrapper.findAllByTestId('extension-list-item').at(at); + + registerExtension(terraformExtension); + + const mockPollingApi = (response, body, header) => { + mock.onGet(endpoint).reply(response, body, header); + }; + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + terraformReportsPath: endpoint, + }, + }, + }); + return axios.waitForAll(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + describe('while loading', () => { + const loadingText = 'Loading Terraform reports...'; + it('should render loading text', async () => { + mockPollingApi(successStatusCode, plans, {}); + createComponent(); + + expect(wrapper.text()).toContain(loadingText); + await waitForPromises(); + expect(wrapper.text()).not.toContain(loadingText); + }); + }); + + describe('when the fetching fails', () => { + beforeEach(() => { + mockPollingApi(errorStatusCode, null, {}); + return createComponent(); + }); + + it('should generate one invalid plan and render correct summary text', () => { + expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + }); + }); + + describe('when the fetching succeeds', () => { + describe.each` + responseType | response | summaryTitle | summarySubtitle + ${'1 invalid report'} | ${{ 0: invalidPlanWithName }} | ${'1 Terraform report failed to generate'} | ${''} + ${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''} + ${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'} + `('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => { + beforeEach(async () => { + mockPollingApi(successStatusCode, response, {}); + return createComponent(); + }); + + it(`should render correct summary text`, () => { + expect(wrapper.text()).toContain(summaryTitle); + + if (summarySubtitle) { + expect(wrapper.text()).toContain(summarySubtitle); + } + }); + }); + }); + }); + + describe('expanded data', () => { + beforeEach(async () => { + mockPollingApi(successStatusCode, plans, {}); + await createComponent(); + + wrapper.findByTestId('toggle-button').trigger('click'); + }); + + describe.each` + reportType | title | subtitle | logLink | lineNumber + ${'a valid report with name'} | ${`The job ${validPlanWithName.job_name} generated a report.`} | ${`Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`} | ${validPlanWithName.job_path} | ${0} + ${'a valid report without name'} | ${'A Terraform report was generated in your pipelines.'} | ${`Reported Resource Changes: ${validPlanWithoutName.create} to add, ${validPlanWithoutName.update} to change, ${validPlanWithoutName.delete} to delete`} | ${validPlanWithoutName.job_path} | ${1} + ${'an invalid report with name'} | ${`The job ${invalidPlanWithName.job_name} failed to generate a report.`} | ${'Generating the report caused an error.'} | ${invalidPlanWithName.job_path} | ${2} + ${'an invalid report without name'} | ${'A Terraform report failed to generate.'} | ${'Generating the report caused an error.'} | ${invalidPlanWithoutName.job_path} | ${3} + `('renders correct text for $reportType', ({ title, subtitle, logLink, lineNumber }) => { + it('renders correct text', () => { + expect(findListItem(lineNumber).text()).toContain(title); + expect(findListItem(lineNumber).text()).toContain(subtitle); + }); + + it(`${logLink ? 'renders' : "doesn't render"} the log link`, () => { + const logText = 'Full log'; + if (logLink) { + expect( + findListItem(lineNumber) + .find('[data-testid="extension-actions-button"]') + .attributes('href'), + ).toBe(logLink); + } else { + expect(findListItem(lineNumber).text()).not.toContain(logText); + } + }); + }); + }); + + describe('polling', () => { + let pollRequest; + let pollStop; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + }); + + describe('successful poll', () => { + beforeEach(() => { + mockPollingApi(successStatusCode, plans, {}); + + return createComponent(); + }); + + it('does not make additional requests after poll is successful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('polling fails', () => { + beforeEach(() => { + mockPollingApi(errorStatusCode, null, {}); + return createComponent(); + }); + + it('generates one broken plan', () => { + expect(wrapper.text()).toContain('1 Terraform report failed to generate'); + }); + + it('does not make additional requests after poll is unsuccessful', () => { + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 4538c1320d0..20d00a116bb 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -282,6 +282,8 @@ export default { gitpod_enabled: true, show_gitpod_button: true, gitpod_url: 'http://gitpod.localhost', + user_preferences_gitpod_path: '/-/profile/preferences#user_gitpod_enabled', + user_profile_enable_gitpod_path: '/-/profile?user%5Bgitpod_enabled%5D=true', }; export const mockStore = { diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 8d41f6620ff..56c9bae0b76 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import Poll from '~/lib/utils/poll'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; import SmartInterval from '~/smart_interval'; @@ -28,6 +29,8 @@ import { workingExtension, collapsedDataErrorExtension, fullDataErrorExtension, + pollingExtension, + pollingErrorExtension, } from './test_extensions'; jest.mock('~/api.js'); @@ -897,13 +900,19 @@ describe('MrWidgetOptions', () => { }); describe('mock extension', () => { + let pollRequest; + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + registerExtension(workingExtension); createComponent(); }); afterEach(() => { + pollRequest.mockRestore(); + registeredExtensions.extensions = []; }); @@ -957,6 +966,66 @@ describe('MrWidgetOptions', () => { expect(collapsedSection.find(GlButton).exists()).toBe(true); expect(collapsedSection.find(GlButton).text()).toBe('Full report'); }); + + it('extension polling is not called if enablePolling flag is not passed', () => { + // called one time due to parent component polling (mount) + expect(pollRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('mock polling extension', () => { + let pollRequest; + let pollStop; + + beforeEach(() => { + pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); + pollStop = jest.spyOn(Poll.prototype, 'stop'); + }); + + afterEach(() => { + pollRequest.mockRestore(); + pollStop.mockRestore(); + + registeredExtensions.extensions = []; + }); + + describe('success', () => { + beforeEach(() => { + registerExtension(pollingExtension); + + createComponent(); + }); + + it('does not make additional requests after poll is successful', () => { + // called two times due to parent component polling (mount) and extension polling + expect(pollRequest).toHaveBeenCalledTimes(2); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('error', () => { + let captureException; + + beforeEach(() => { + captureException = jest.spyOn(Sentry, 'captureException'); + + registerExtension(pollingErrorExtension); + + createComponent(); + }); + + it('does not make additional requests after poll has failed', () => { + // called two times due to parent component polling (mount) and extension polling + expect(pollRequest).toHaveBeenCalledTimes(2); + expect(pollStop).toHaveBeenCalledTimes(1); + }); + + it('captures sentry error and displays error when poll has failed', () => { + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + }); + }); }); describe('mock extension errors', () => { diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index 6eb68a1b00d..3cdb4265ef0 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -15,6 +15,8 @@ describe('MergeRequestStore', () => { gitpodEnabled: mockData.gitpod_enabled, showGitpodButton: mockData.show_gitpod_button, gitpodUrl: mockData.gitpod_url, + userPreferencesGitpodPath: mockData.user_preferences_gitpod_path, + userProfileEnableGitpodPath: mockData.user_profile_enable_gitpod_path, }); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index c7ff02ab726..986c1d6545a 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -97,3 +97,13 @@ export const fullDataErrorExtension = { }, }, }; + +export const pollingExtension = { + ...workingExtension, + enablePolling: true, +}; + +export const pollingErrorExtension = { + ...collapsedDataErrorExtension, + enablePolling: true, +}; diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 1fc655f1ebc..221beed744b 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -349,6 +349,8 @@ describe('AlertDetails', () => { ${1} | ${'metrics'} ${2} | ${'activity'} `('will navigate to the correct tab via $tabId', ({ index, tabId }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentTabIndex: index }); expect($router.replace).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); }); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index 9ae45071f45..29e0eee2c9a 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -109,6 +109,8 @@ describe('Alert Details Sidebar Assignees', () => { }); it('renders a unassigned option', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isDropdownSearching: false }); await wrapper.vm.$nextTick(); expect(findDropdown().text()).toBe('Unassigned'); @@ -120,6 +122,8 @@ describe('Alert Details Sidebar Assignees', () => { it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isDropdownSearching: false }); await wrapper.vm.$nextTick(); @@ -136,6 +140,8 @@ describe('Alert Details Sidebar Assignees', () => { }); it('emits an error when request contains error messages', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ isDropdownSearching: false }); const errorMutationResult = { data: { diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js index 530d01402c6..083a5f60d1d 100644 --- a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js +++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js @@ -315,6 +315,8 @@ describe('vue_shared/components/chronic_duration_input', () => { }); it('passes updated prop via v-model', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ value: MOCK_VALUE }); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 33445923a49..fca5e664a96 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,8 +1,16 @@ import { GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import initCopyToClipboard from '~/behaviors/copy_to_clipboard'; +import { nextTick } from 'vue'; + +import initCopyToClipboard, { + CLIPBOARD_SUCCESS_EVENT, + CLIPBOARD_ERROR_EVENT, + I18N_ERROR_MESSAGE, +} from '~/behaviors/copy_to_clipboard'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('clipboard button', () => { let wrapper; @@ -15,6 +23,42 @@ describe('clipboard button', () => { const findButton = () => wrapper.find(GlButton); + const expectConfirmationTooltip = async ({ event, message }) => { + const title = 'Copy this value'; + + createWrapper({ + text: 'copy me', + title, + }); + + wrapper.vm.$root.$emit = jest.fn(); + + const button = findButton(); + + expect(button.attributes()).toMatchObject({ + title, + 'aria-label': title, + }); + + await button.trigger(event); + + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::show::tooltip', 'clipboard-button-1'); + + expect(button.attributes()).toMatchObject({ + title: message, + 'aria-label': message, + }); + + jest.runAllTimers(); + await nextTick(); + + expect(button.attributes()).toMatchObject({ + title, + 'aria-label': title, + }); + expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::hide::tooltip', 'clipboard-button-1'); + }; + afterEach(() => { wrapper.destroy(); wrapper = null; @@ -99,6 +143,32 @@ describe('clipboard button', () => { expect(findButton().props('variant')).toBe(variant); }); + describe('confirmation tooltip', () => { + it('adds `id` and `data-clipboard-handle-tooltip` attributes to button', () => { + createWrapper({ + text: 'copy me', + title: 'Copy this value', + }); + + expect(findButton().attributes()).toMatchObject({ + id: 'clipboard-button-1', + 'data-clipboard-handle-tooltip': 'false', + 'aria-live': 'polite', + }); + }); + + it('shows success tooltip after successful copy', () => { + expectConfirmationTooltip({ + event: CLIPBOARD_SUCCESS_EVENT, + message: ClipboardButton.i18n.copied, + }); + }); + + it('shows error tooltip after failed copy', () => { + expectConfirmationTooltip({ event: CLIPBOARD_ERROR_EVENT, message: I18N_ERROR_MESSAGE }); + }); + }); + describe('integration', () => { it('actually copies to clipboard', () => { initCopyToClipboard(); diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js index af7f85769aa..a179afccae0 100644 --- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js +++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js @@ -10,6 +10,7 @@ describe('Confirm Danger Modal', () => { const phrase = 'En Taro Adun'; const buttonText = 'Click me!'; const buttonClass = 'gl-w-full'; + const buttonVariant = 'info'; const modalId = CONFIRM_DANGER_MODAL_ID; const findBtn = () => wrapper.findComponent(GlButton); @@ -21,6 +22,7 @@ describe('Confirm Danger Modal', () => { propsData: { buttonText, buttonClass, + buttonVariant, phrase, ...props, }, @@ -57,6 +59,10 @@ describe('Confirm Danger Modal', () => { expect(findBtn().classes()).toContain(buttonClass); }); + it('passes `buttonVariant` prop to button', () => { + expect(findBtn().attributes('variant')).toBe(buttonVariant); + }); + it('will emit `confirm` when the modal confirms', () => { expect(wrapper.emitted('confirm')).toBeUndefined(); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 64d15884333..4e9eac2dde2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -122,6 +122,8 @@ describe('FilteredSearchBarRoot', () => { describe('sortDirectionIcon', () => { it('returns string "sort-lowest" when `selectedSortDirection` is "ascending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.ascending, }); @@ -130,6 +132,8 @@ describe('FilteredSearchBarRoot', () => { }); it('returns string "sort-highest" when `selectedSortDirection` is "descending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.descending, }); @@ -140,6 +144,8 @@ describe('FilteredSearchBarRoot', () => { describe('sortDirectionTooltip', () => { it('returns string "Sort direction: Ascending" when `selectedSortDirection` is "ascending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.ascending, }); @@ -148,6 +154,8 @@ describe('FilteredSearchBarRoot', () => { }); it('returns string "Sort direction: Descending" when `selectedSortDirection` is "descending"', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortDirection: SortDirection.descending, }); @@ -158,6 +166,8 @@ describe('FilteredSearchBarRoot', () => { describe('filteredRecentSearches', () => { it('returns array of recent searches filtering out any string type (unsupported) items', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ recentSearches: [{ foo: 'bar' }, 'foo'], }); @@ -169,6 +179,8 @@ describe('FilteredSearchBarRoot', () => { }); it('returns array of recent searches sanitizing any duplicate token values', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ recentSearches: [ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel], @@ -198,6 +210,8 @@ describe('FilteredSearchBarRoot', () => { describe('filterValue', () => { it('emits component event `onFilter` with empty array and false when filter was never selected', () => { wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ initialRender: false, filterValue: [tokenValueEmpty], @@ -210,6 +224,8 @@ describe('FilteredSearchBarRoot', () => { it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', () => { wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ initialRender: false, filterValue: [tokenValueEmpty], @@ -264,6 +280,8 @@ describe('FilteredSearchBarRoot', () => { describe('handleSortDirectionClick', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortOption: mockSortOptions[0], }); @@ -312,6 +330,8 @@ describe('FilteredSearchBarRoot', () => { const mockFilters = [tokenValueAuthor, 'foo']; beforeEach(async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ filterValue: mockFilters, }); @@ -376,6 +396,8 @@ describe('FilteredSearchBarRoot', () => { describe('template', () => { beforeEach(() => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortOption: mockSortOptions[0], selectedSortDirection: SortDirection.descending, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index b29c394e7ae..5865c6a41b8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -10,10 +10,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { - DEFAULT_LABEL_ANY, - DEFAULT_NONE_ANY, -} from '~/vue_shared/components/filtered_search_bar/constants'; +import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -227,6 +224,8 @@ describe('AuthorToken', () => { expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ authors: [ { @@ -274,7 +273,7 @@ describe('AuthorToken', () => { expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => { + it('renders `DEFAULT_NONE_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, config: { ...mockAuthorToken, preloadedAuthors: mockPreloadedAuthors }, @@ -285,8 +284,9 @@ describe('AuthorToken', () => { const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(1 + currentUserLength); - expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text); + expect(suggestions).toHaveLength(2 + currentUserLength); + expect(suggestions.at(0).text()).toBe(DEFAULT_NONE_ANY[0].text); + expect(suggestions.at(1).text()).toBe(DEFAULT_NONE_ANY[1].text); }); it('emits listeners in the base-token', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index f3e8b2d0c1b..cd8be765fb5 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -121,6 +121,8 @@ describe('BranchToken', () => { beforeEach(async () => { wrapper = createComponent({ value: { data: mockBranches[0].name } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ branches: mockBranches, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index 36071c900df..ed9ac7c271e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -123,6 +123,8 @@ describe('EmojiToken', () => { value: { data: `"${mockEmojis[0].name}"` }, }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ emojis: mockEmojis, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index f55fb2836e3..b9af71ad8a7 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -144,6 +144,8 @@ describe('LabelToken', () => { beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labels: mockLabels, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index 4a098db33c5..c0d8b5fd139 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -121,6 +121,8 @@ describe('MilestoneToken', () => { beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } }); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ milestones: mockMilestones, }); diff --git a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js new file mode 100644 index 00000000000..b673e5407d4 --- /dev/null +++ b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js @@ -0,0 +1,77 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import flushPromises from 'helpers/flush_promises'; +import axios from '~/lib/utils/axios_utils'; +import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; + +describe('GitlabVersionCheck', () => { + let wrapper; + let mock; + + const defaultResponse = { + code: 200, + res: { severity: 'success' }, + }; + + const createComponent = (mockResponse) => { + const response = { + ...defaultResponse, + ...mockResponse, + }; + + mock = new MockAdapter(axios); + mock.onGet().replyOnce(response.code, response.res); + + wrapper = shallowMount(GitlabVersionCheck); + }; + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const findGlBadge = () => wrapper.findComponent(GlBadge); + + describe('template', () => { + describe.each` + description | mockResponse | renders + ${'successful but null'} | ${{ code: 200, res: null }} | ${false} + ${'successful and valid'} | ${{ code: 200, res: { severity: 'success' } }} | ${true} + ${'an error'} | ${{ code: 500, res: null }} | ${false} + `('version_check.json response', ({ description, mockResponse, renders }) => { + describe(`is ${description}`, () => { + beforeEach(async () => { + createComponent(mockResponse); + await flushPromises(); // Ensure we wrap up the axios call + }); + + it(`does${renders ? '' : ' not'} render GlBadge`, () => { + expect(findGlBadge().exists()).toBe(renders); + }); + }); + }); + + describe.each` + mockResponse | expectedUI + ${{ code: 200, res: { severity: 'success' } }} | ${{ title: 'Up to date', variant: 'success' }} + ${{ code: 200, res: { severity: 'warning' } }} | ${{ title: 'Update available', variant: 'warning' }} + ${{ code: 200, res: { severity: 'danger' } }} | ${{ title: 'Update ASAP', variant: 'danger' }} + `('badge ui', ({ mockResponse, expectedUI }) => { + describe(`when response is ${mockResponse.res.severity}`, () => { + beforeEach(async () => { + createComponent(mockResponse); + await flushPromises(); // Ensure we wrap up the axios call + }); + + it(`title is ${expectedUI.title}`, () => { + expect(findGlBadge().text()).toBe(expectedUI.title); + }); + + it(`variant is ${expectedUI.variant}`, () => { + expect(findGlBadge().attributes('variant')).toBe(expectedUI.variant); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js index 5bedd0ccd02..38c26226863 100644 --- a/spec/frontend/vue_shared/components/line_numbers_spec.js +++ b/spec/frontend/vue_shared/components/line_numbers_spec.js @@ -13,7 +13,6 @@ describe('Line Numbers component', () => { const findGlIcon = () => wrapper.findComponent(GlIcon); const findLineNumbers = () => wrapper.findAllComponents(GlLink); const findFirstLineNumber = () => findLineNumbers().at(0); - const findSecondLineNumber = () => findLineNumbers().at(1); beforeEach(() => createComponent()); @@ -24,7 +23,7 @@ describe('Line Numbers component', () => { expect(findLineNumbers().length).toBe(lines); expect(findFirstLineNumber().attributes()).toMatchObject({ id: 'L1', - href: '#L1', + to: '#LC1', }); }); @@ -35,37 +34,4 @@ describe('Line Numbers component', () => { }); }); }); - - describe('clicking a line number', () => { - let firstLineNumber; - let firstLineNumberElement; - - beforeEach(() => { - firstLineNumber = findFirstLineNumber(); - firstLineNumberElement = firstLineNumber.element; - - jest.spyOn(firstLineNumberElement, 'scrollIntoView'); - jest.spyOn(firstLineNumberElement.classList, 'add'); - jest.spyOn(firstLineNumberElement.classList, 'remove'); - - firstLineNumber.vm.$emit('click'); - }); - - it('adds the highlight (hll) class', () => { - expect(firstLineNumberElement.classList.add).toHaveBeenCalledWith('hll'); - }); - - it('removes the highlight (hll) class from a previously highlighted line', () => { - findSecondLineNumber().vm.$emit('click'); - - expect(firstLineNumberElement.classList.remove).toHaveBeenCalledWith('hll'); - }); - - it('scrolls the line into view', () => { - expect(firstLineNumberElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 76e1a1162ad..0d90ca7f1f6 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; @@ -242,6 +243,41 @@ describe('Markdown field component', () => { expect(dropzoneSpy).toHaveBeenCalled(); }); + + describe('mentioning all users', () => { + const users = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => `user_${i}`); + + it('shows warning on mention of all users', async () => { + axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + + subject.setProps({ textareaValue: 'hello @all' }); + + await axios.waitFor(markdownPreviewPath).then(() => { + expect(subject.text()).toContain( + 'You are about to add 11 people to the discussion. They will all receive a notification.', + ); + }); + }); + + it('removes warning when all mention is removed', async () => { + axiosMock.onPost(markdownPreviewPath).reply(200, { references: { users } }); + + subject.setProps({ textareaValue: 'hello @all' }); + + await axios.waitFor(markdownPreviewPath); + + jest.spyOn(axios, 'post'); + + subject.setProps({ textareaValue: 'hello @allan' }); + + await nextTick(); + + expect(axios.post).not.toHaveBeenCalled(); + expect(subject.text()).not.toContain( + 'You are about to add 11 people to the discussion. They will all receive a notification.', + ); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index acf97713885..b330b4f5657 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -313,6 +313,8 @@ describe('AlertManagementEmptyState', () => { it('returns correctly applied filter search values', async () => { const searchTerm = 'foo'; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchTerm, }); @@ -330,6 +332,8 @@ describe('AlertManagementEmptyState', () => { }); it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ authorUsername: 'foo', searchTerm: 'bar', diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index 23cf6ef9785..e8d76991b90 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -3,7 +3,7 @@ exports[`Package code instruction multiline to match the snapshot 1`] = ` <div> <label - for="instruction-input_3" + for="instruction-input_1" > foo_label </label> @@ -23,7 +23,7 @@ multiline text exports[`Package code instruction single line to match the default snapshot 1`] = ` <div> <label - for="instruction-input_2" + for="instruction-input_1" > foo_label </label> @@ -37,7 +37,7 @@ exports[`Package code instruction single line to match the default snapshot 1`] <input class="form-control gl-font-monospace" data-testid="instruction-input" - id="instruction-input_2" + id="instruction-input_1" readonly="readonly" type="text" /> @@ -47,9 +47,12 @@ exports[`Package code instruction single line to match the default snapshot 1`] data-testid="instruction-button" > <button - aria-label="Copy this value" + aria-label="Copy npm install command" + aria-live="polite" class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" + data-clipboard-handle-tooltip="false" data-clipboard-text="npm i @my-package" + id="clipboard-button-1" title="Copy npm install command" type="button" > diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js index 4ec608aaf07..3a2ea263a05 100644 --- a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js +++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js @@ -3,6 +3,8 @@ import Tracking from '~/tracking'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1)); + describe('Package code instruction', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js index a5a099d803a..5336ecc614c 100644 --- a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js @@ -68,6 +68,8 @@ describe('IssuableMoveDropdown', () => { describe('searchKey', () => { it('calls `fetchProjects` with value of the prop', async () => { jest.spyOn(wrapper.vm, 'fetchProjects'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'foo', }); @@ -143,6 +145,8 @@ describe('IssuableMoveDropdown', () => { `( 'returns $returnValue when selectedProject and provided project param $title', async ({ project, selectedProject, returnValue }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject, }); @@ -154,6 +158,8 @@ describe('IssuableMoveDropdown', () => { ); it('returns false when selectedProject is null', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject: null, }); @@ -206,6 +212,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders gl-loading-icon component when projectsListLoading prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projectsListLoading: true, }); @@ -216,6 +224,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders gl-dropdown-item components for available projects', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: mockProjects, selectedProject: mockProjects[0], @@ -234,6 +244,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders string "No matching results" when search does not yield any matches', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'foo', }); @@ -241,6 +253,8 @@ describe('IssuableMoveDropdown', () => { // Wait for `searchKey` watcher to run. await wrapper.vm.$nextTick(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: [], projectsListLoading: false, @@ -254,6 +268,8 @@ describe('IssuableMoveDropdown', () => { }); it('renders string "Failed to load projects" when loading projects list fails', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: [], projectsListLoading: false, @@ -273,6 +289,8 @@ describe('IssuableMoveDropdown', () => { expect(moveButtonEl.text()).toBe('Move'); expect(moveButtonEl.attributes('disabled')).toBe('true'); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject: mockProjects[0], }); @@ -303,6 +321,8 @@ describe('IssuableMoveDropdown', () => { }); it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projectItemClick: true, }); @@ -326,6 +346,8 @@ describe('IssuableMoveDropdown', () => { }); it('sets project for clicked gl-dropdown-item to selectedProject', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ projects: mockProjects, }); @@ -338,6 +360,8 @@ describe('IssuableMoveDropdown', () => { }); it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedProject: mockProjects[0], }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js index 1fe85637a62..0eff6a1dace 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js @@ -43,6 +43,8 @@ describe('DropdownContentsCreateView', () => { }); it('returns `true` when `labelCreateInProgress` is true', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labelTitle: 'Foo', selectedColor: '#ff0000', @@ -55,6 +57,8 @@ describe('DropdownContentsCreateView', () => { }); it('returns `false` when label title and color is defined and create request is not already in progress', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labelTitle: 'Foo', selectedColor: '#ff0000', @@ -99,6 +103,8 @@ describe('DropdownContentsCreateView', () => { describe('handleCreateClick', () => { it('calls action `createLabel` with object containing `labelTitle` & `selectedColor`', () => { jest.spyOn(wrapper.vm, 'createLabel').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ labelTitle: 'Foo', selectedColor: '#ff0000', @@ -164,6 +170,8 @@ describe('DropdownContentsCreateView', () => { }); it('renders color input element', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedColor: '#ff0000', }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 80b8edd28ba..93a0e2f75bb 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -63,6 +63,8 @@ describe('DropdownContentsLabelsView', () => { describe('computed', () => { describe('visibleLabels', () => { it('returns matching labels filtered with `searchKey`', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'bug', }); @@ -72,6 +74,8 @@ describe('DropdownContentsLabelsView', () => { }); it('returns matching labels with fuzzy filtering', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'bg', }); @@ -82,6 +86,8 @@ describe('DropdownContentsLabelsView', () => { }); it('returns all labels when `searchKey` is empty', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: '', }); @@ -100,6 +106,8 @@ describe('DropdownContentsLabelsView', () => { `( 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription', async ({ searchKey, labels, returnValue }) => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey, }); @@ -161,6 +169,8 @@ describe('DropdownContentsLabelsView', () => { describe('handleKeyDown', () => { it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -173,6 +183,8 @@ describe('DropdownContentsLabelsView', () => { }); it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -185,6 +197,8 @@ describe('DropdownContentsLabelsView', () => { }); it('resets the search text when the Enter key is pressed', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, searchKey: 'bug', @@ -201,6 +215,8 @@ describe('DropdownContentsLabelsView', () => { it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 2, }); @@ -220,6 +236,8 @@ describe('DropdownContentsLabelsView', () => { it('calls action `toggleDropdownContents` when Esc key is pressed', () => { jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -233,6 +251,8 @@ describe('DropdownContentsLabelsView', () => { it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => { jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 1, }); @@ -320,6 +340,8 @@ describe('DropdownContentsLabelsView', () => { }); it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentHighlightItem: 0, }); @@ -332,6 +354,8 @@ describe('DropdownContentsLabelsView', () => { }); it('renders element containing "No matching results" when `searchKey` does not match with any label', () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ searchKey: 'abc', }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index d8491334b5d..3ceed670d77 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -9,6 +9,7 @@ import { workspaceLabelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; import { + mockRegularLabel, mockSuggestedColors, createLabelSuccessfulResponse, workspaceLabelsQueryResponse, @@ -25,8 +26,18 @@ const userRecoverableError = { errors: ['Houston, we have a problem'], }; +const titleTakenError = { + data: { + labelCreate: { + label: mockRegularLabel, + errors: ['Title has already been taken'], + }, + }, +}; + const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse); const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError); +const createLabelDuplicateErrorHandler = jest.fn().mockResolvedValue(titleTakenError); const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); describe('DropdownContentsCreateView', () => { @@ -208,4 +219,17 @@ describe('DropdownContentsCreateView', () => { expect(createFlash).toHaveBeenCalled(); }); + + it('displays error in alert if label title is already taken', async () => { + createComponent({ mutationHandler: createLabelDuplicateErrorHandler }); + fillLabelAttributes(); + await nextTick(); + + findCreateButton().vm.$emit('click'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).text()).toEqual( + titleTakenError.data.labelCreate.errors[0], + ); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 6f5a4b7e613..7f6770e0bea 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -110,6 +110,19 @@ describe('DropdownContentsLabelsView', () => { }); }); + it('first item is highlighted when search is not empty', async () => { + createComponent({ + queryHandler: jest.fn().mockResolvedValue(workspaceLabelsQueryResponse), + searchKey: 'Label', + }); + await makeObserverAppear(); + await waitForPromises(); + await nextTick(); + + expect(findLabelsList().exists()).toBe(true); + expect(findFirstLabel().attributes('active')).toBe('true'); + }); + it('when search returns 0 results', async () => { createComponent({ queryHandler: jest.fn().mockResolvedValue({ diff --git a/spec/frontend/vue_shared/components/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer_spec.js index 758068379de..094d8d42a47 100644 --- a/spec/frontend/vue_shared/components/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer_spec.js @@ -1,27 +1,35 @@ import hljs from 'highlight.js/lib/core'; +import Vue, { nextTick } from 'vue'; +import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer.vue'; import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import waitForPromises from 'helpers/wait_for_promises'; jest.mock('highlight.js/lib/core'); +Vue.use(VueRouter); +const router = new VueRouter(); describe('Source Viewer component', () => { let wrapper; const content = `// Some source code`; - const highlightedContent = `<span data-testid='test-highlighted'>${content}</span>`; + const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const language = 'javascript'; hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); const createComponent = async (props = {}) => { - wrapper = shallowMountExtended(SourceViewer, { propsData: { content, language, ...props } }); + wrapper = shallowMountExtended(SourceViewer, { + router, + propsData: { content, language, ...props }, + }); await waitForPromises(); }; const findLineNumbers = () => wrapper.findComponent(LineNumbers); const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); + const findFirstLine = () => wrapper.find('#LC1'); beforeEach(() => createComponent()); @@ -56,4 +64,39 @@ describe('Source Viewer component', () => { expect(findHighlightedContent().exists()).toBe(true); }); }); + + describe('selecting a line', () => { + let firstLine; + let firstLineElement; + + beforeEach(() => { + firstLine = findFirstLine(); + firstLineElement = firstLine.element; + + jest.spyOn(firstLineElement, 'scrollIntoView'); + jest.spyOn(firstLineElement.classList, 'add'); + jest.spyOn(firstLineElement.classList, 'remove'); + }); + + it('adds the highlight (hll) class', async () => { + wrapper.vm.$router.push('#LC1'); + await nextTick(); + + expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll'); + }); + + it('removes the highlight (hll) class from a previously highlighted line', async () => { + wrapper.vm.$router.push('#LC2'); + await nextTick(); + + expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll'); + }); + + it('scrolls the line into view', () => { + expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 92938b2717f..659d93d6597 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,11 +1,18 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import { nextTick } from 'vue'; + import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; + const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; +const TEST_USER_PREFERENCES_GITPOD_PATH = '/-/profile/preferences#user_gitpod_enabled'; +const TEST_USER_PROFILE_ENABLE_GITPOD_PATH = '/-/profile?user%5Bgitpod_enabled%5D=true'; const ACTION_EDIT = { href: TEST_EDIT_URL, @@ -54,21 +61,31 @@ const ACTION_GITPOD = { }; const ACTION_GITPOD_ENABLE = { ...ACTION_GITPOD, - href: '#modal-enable-gitpod', + href: undefined, handle: expect.any(Function), }; describe('Web IDE link component', () => { let wrapper; - function createComponent(props) { - wrapper = shallowMount(WebIdeLink, { + function createComponent(props, mountFn = shallowMountExtended) { + wrapper = mountFn(WebIdeLink, { propsData: { editUrl: TEST_EDIT_URL, webIdeUrl: TEST_WEB_IDE_URL, gitpodUrl: TEST_GITPOD_URL, ...props, }, + stubs: { + GlModal: stubComponent(GlModal, { + template: ` + <div> + <slot name="modal-title"></slot> + <slot></slot> + <slot name="modal-footer"></slot> + </div>`, + }), + }, }); } @@ -78,6 +95,7 @@ describe('Web IDE link component', () => { const findActionsButton = () => wrapper.find(ActionsButton); const findLocalStorageSync = () => wrapper.find(LocalStorageSync); + const findModal = () => wrapper.findComponent(GlModal); it.each([ { @@ -97,19 +115,68 @@ describe('Web IDE link component', () => { expectedActions: [ACTION_WEB_IDE_CONFIRM_FORK, ACTION_EDIT_CONFIRM_FORK], }, { - props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }, + props: { + showWebIdeButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: true, + }, expectedActions: [ACTION_EDIT, ACTION_GITPOD], }, { - props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }, + props: { + showWebIdeButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + gitpodEnabled: true, + }, + expectedActions: [ACTION_EDIT], + }, + { + props: { + showWebIdeButton: false, + showGitpodButton: true, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: true, + }, + expectedActions: [ACTION_EDIT], + }, + { + props: { + showWebIdeButton: false, + showGitpodButton: true, + gitpodEnabled: true, + }, + expectedActions: [ACTION_EDIT], + }, + { + props: { + showWebIdeButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + }, expectedActions: [ACTION_EDIT, ACTION_GITPOD_ENABLE], }, { - props: { showGitpodButton: true, gitpodEnabled: false }, + props: { + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + }, expectedActions: [ACTION_WEB_IDE, ACTION_EDIT, ACTION_GITPOD_ENABLE], }, { - props: { showEditButton: false, showGitpodButton: true, gitpodText: 'Test Gitpod' }, + props: { + showEditButton: false, + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodText: 'Test Gitpod', + }, expectedActions: [ACTION_WEB_IDE, { ...ACTION_GITPOD_ENABLE, text: 'Test Gitpod' }], }, { @@ -128,6 +195,8 @@ describe('Web IDE link component', () => { showEditButton: false, showWebIdeButton: true, showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, gitpodEnabled: true, }); }); @@ -174,7 +243,7 @@ describe('Web IDE link component', () => { ])( 'emits the correct event when an action handler is called', async ({ props, expectedEventPayload }) => { - createComponent({ ...props, needsToFork: true }); + createComponent({ ...props, needsToFork: true, disableForkModal: true }); findActionsButton().props('actions')[0].handle(); @@ -182,4 +251,72 @@ describe('Web IDE link component', () => { }, ); }); + + describe('when Gitpod is not enabled', () => { + it('renders closed modal to enable Gitpod', () => { + createComponent({ + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + }); + + const modal = findModal(); + + expect(modal.exists()).toBe(true); + expect(modal.props()).toMatchObject({ + visible: false, + modalId: 'enable-gitpod-modal', + size: 'sm', + title: WebIdeLink.i18n.modal.title, + actionCancel: { + text: WebIdeLink.i18n.modal.actionCancelText, + }, + actionPrimary: { + text: WebIdeLink.i18n.modal.actionPrimaryText, + attributes: { + variant: 'confirm', + category: 'primary', + href: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + 'data-method': 'put', + }, + }, + }); + }); + + it('opens modal when `Gitpod` action is clicked', async () => { + const gitpodText = 'Open in Gitpod'; + + createComponent( + { + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: false, + gitpodText, + }, + mountExtended, + ); + + findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); + + await nextTick(); + await wrapper.findByRole('button', { name: gitpodText }).trigger('click'); + + expect(findModal().props('visible')).toBe(true); + }); + }); + + describe('when Gitpod is enabled', () => { + it('does not render modal', () => { + createComponent({ + showGitpodButton: true, + userPreferencesGitpodPath: TEST_USER_PREFERENCES_GITPOD_PATH, + userProfileEnableGitpodPath: TEST_USER_PROFILE_ENABLE_GITPOD_PATH, + gitpodEnabled: true, + }); + + expect(findModal().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js index d7d7f4edc3f..b3f94d0242a 100644 --- a/spec/frontend/vue_shared/directives/track_event_spec.js +++ b/spec/frontend/vue_shared/directives/track_event_spec.js @@ -38,6 +38,8 @@ describe('Error Tracking directive', () => { label: 'Trackable Info', }; + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ trackingOptions }); const { category, action, label, property, value } = trackingOptions; diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 5979a65e3cd..14e93108447 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -98,6 +98,8 @@ describe('IssuableListRoot', () => { await wrapper.vm.$nextTick(); + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ checkedIssuables, }); @@ -111,6 +113,8 @@ describe('IssuableListRoot', () => { describe('bulkEditIssuables', () => { it('returns array of issuables which have `checked` set to true within checkedIssuables map', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ checkedIssuables: mockCheckedIssuables, }); @@ -180,6 +184,8 @@ describe('IssuableListRoot', () => { describe('issuableChecked', () => { it('returns boolean value representing checked status of issuable item', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ checkedIssuables: { [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] }, diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js index 8c22b67bdbe..5723e2da586 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js @@ -1,5 +1,6 @@ import { GlTab, GlBadge } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import { setLanguage } from 'helpers/locale_helper'; import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue'; @@ -27,10 +28,12 @@ describe('IssuableTabs', () => { let wrapper; beforeEach(() => { + setLanguage('en'); wrapper = createComponent(); }); afterEach(() => { + setLanguage(null); wrapper.destroy(); }); @@ -71,7 +74,7 @@ describe('IssuableTabs', () => { // Does not render `All` badge since it has an undefined count expect(badges).toHaveLength(2); - expect(badges.at(0).text()).toBe(`${mockIssuableListProps.tabCounts.opened}`); + expect(badges.at(0).text()).toBe('5,000'); expect(badges.at(1).text()).toBe(`${mockIssuableListProps.tabCounts.closed}`); }); diff --git a/spec/frontend/vue_shared/issuable/list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js index e2fa99f7cc9..cfc7937b412 100644 --- a/spec/frontend/vue_shared/issuable/list/mock_data.js +++ b/spec/frontend/vue_shared/issuable/list/mock_data.js @@ -133,7 +133,7 @@ export const mockTabs = [ ]; export const mockTabCounts = { - opened: 5, + opened: 5000, closed: 0, all: undefined, }; diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js index 1fcf37a0477..cb418371760 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js @@ -84,6 +84,8 @@ describe('IssuableTitle', () => { }); it('renders sticky header when `stickyTitleVisible` prop is true', async () => { + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax wrapper.setData({ stickyTitleVisible: true, }); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 02795751f33..ea26b2b4fb3 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; @@ -15,6 +16,7 @@ Vue.use(VueApollo); const WORK_ITEM_ID = '1'; describe('Work items root component', () => { + const mockUpdatedTitle = 'Updated title'; let wrapper; let fakeApollo; @@ -53,7 +55,6 @@ describe('Work items root component', () => { it('updates the title when it is edited', async () => { createComponent(); jest.spyOn(wrapper.vm.$apollo, 'mutate'); - const mockUpdatedTitle = 'Updated title'; await findTitle().vm.$emit('title-changed', mockUpdatedTitle); @@ -91,4 +92,32 @@ describe('Work items root component', () => { expect(findTitle().exists()).toBe(false); }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + + createComponent(); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks item title updates', async () => { + await findTitle().vm.$emit('title-changed', mockUpdatedTitle); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, { + action: 'updated_title', + category: 'workItems:show', + label: 'item_title', + property: '[type_work_item]', + }); + }); + }); }); diff --git a/spec/graphql/mutations/ci/runner/delete_spec.rb b/spec/graphql/mutations/ci/runner/delete_spec.rb index 27e8236d593..9f30c95edd5 100644 --- a/spec/graphql/mutations/ci/runner/delete_spec.rb +++ b/spec/graphql/mutations/ci/runner/delete_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe Mutations::Ci::Runner::Delete do include GraphqlHelpers - let_it_be(:user) { create(:user) } let_it_be(:runner) { create(:ci_runner) } + let(:user) { create(:user) } let(:current_ctx) { { current_user: user } } let(:mutation_params) do @@ -46,10 +46,10 @@ RSpec.describe Mutations::Ci::Runner::Delete do end context 'when user can delete owned runner' do - let_it_be(:project) { create(:project, creator_id: user.id) } - let_it_be(:project_runner, reload: true) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) } + let!(:project) { create(:project, creator_id: user.id) } + let!(:project_runner) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) } - before_all do + before do project.add_maintainer(user) end @@ -63,10 +63,10 @@ RSpec.describe Mutations::Ci::Runner::Delete do end context 'with more than one associated project' do - let_it_be(:project2) { create(:project, creator_id: user.id) } - let_it_be(:two_projects_runner) { create(:ci_runner, :project, description: 'Two projects runner', projects: [project, project2]) } + let!(:project2) { create(:project, creator_id: user.id) } + let!(:two_projects_runner) { create(:ci_runner, :project, description: 'Two projects runner', projects: [project, project2]) } - before_all do + before do project2.add_maintainer(user) end diff --git a/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb new file mode 100644 index 00000000000..f5f4c0cefad --- /dev/null +++ b/spec/graphql/mutations/clusters/agent_tokens/revoke_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::AgentTokens::Revoke do + let_it_be(:token) { create(:cluster_agent_token) } + let_it_be(:user) { create(:user) } + + let(:mutation) do + described_class.new( + object: double, + context: { current_user: user }, + field: double + ) + end + + it { expect(described_class.graphql_name).to eq('ClusterAgentTokenRevoke') } + it { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + describe '#resolve' do + let(:global_id) { token.to_global_id } + + subject { mutation.resolve(id: global_id) } + + context 'user does not have permission' do + it 'does not revoke the token' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + + expect(token.reload).not_to be_revoked + end + end + + context 'user has permission' do + before do + token.agent.project.add_maintainer(user) + end + + it 'revokes the token' do + subject + + expect(token.reload).to be_revoked + end + + context 'supplied ID is invalid' do + let(:global_id) { token.id } + + it 'raises a coercion error' do + expect { subject }.to raise_error(::GraphQL::CoercionError) + + expect(token.reload).not_to be_revoked + end + end + end + end +end diff --git a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb index 0f05504d4f2..d17d11305b1 100644 --- a/spec/graphql/mutations/customer_relations/contacts/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/contacts/create_spec.rb @@ -4,8 +4,8 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Contacts::Create do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let(:group) { create(:group, :crm_enabled) } let(:not_found_or_does_not_belong) { 'The specified organization was not found or does not belong to this group' } let(:valid_params) do attributes_for(:contact, @@ -34,11 +34,11 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do end context 'when the user has permission' do - before_all do + before do group.add_developer(user) end - context 'when the feature is disabled' do + context 'when the feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -49,6 +49,15 @@ RSpec.describe Mutations::CustomerRelations::Contacts::Create do end end + context 'when crm_enabled is false' do + let(:group) { create(:group) } + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end + context 'when the params are invalid' do it 'returns the validation error' do valid_params[:first_name] = nil diff --git a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb index 4f59de194fd..c8206eca442 100644 --- a/spec/graphql/mutations/customer_relations/contacts/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/contacts/update_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Contacts::Update do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:first_name) { 'Lionel' } let(:last_name) { 'Smith' } diff --git a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb index 9be0f5d4289..ee78d2b16f6 100644 --- a/spec/graphql/mutations/customer_relations/organizations/create_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/create_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Create do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:valid_params) do attributes_for(:organization, diff --git a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb index e3aa8eafe0c..90fd7a0a9f1 100644 --- a/spec/graphql/mutations/customer_relations/organizations/update_spec.rb +++ b/spec/graphql/mutations/customer_relations/organizations/update_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Mutations::CustomerRelations::Organizations::Update do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:name) { 'GitLab' } let(:default_rate) { 1000.to_f } @@ -56,7 +56,7 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do expect(resolve_mutation[:organization]).to have_attributes(attributes) end - context 'when the feature is disabled' do + context 'when the feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -66,6 +66,15 @@ RSpec.describe Mutations::CustomerRelations::Organizations::Update do .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") end end + + context 'when the feature is disabled' do + let_it_be(:group) { create(:group) } + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end end end diff --git a/spec/graphql/mutations/issues/set_escalation_status_spec.rb b/spec/graphql/mutations/issues/set_escalation_status_spec.rb new file mode 100644 index 00000000000..d41118b1812 --- /dev/null +++ b/spec/graphql/mutations/issues/set_escalation_status_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Issues::SetEscalationStatus do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:issue, reload: true) { create(:incident, project: project) } + let_it_be(:escalation_status, reload: true) { create(:incident_management_issuable_escalation_status, issue: issue) } + + let(:status) { :acknowledged } + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + describe '#resolve' do + let(:args) { { status: status } } + let(:mutated_issue) { result[:issue] } + + subject(:result) { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, **args) } + + it_behaves_like 'permission level for issue mutation is correctly verified', true + + context 'when the user can update the issue' do + before_all do + project.add_reporter(user) + end + + it_behaves_like 'permission level for issue mutation is correctly verified', true + + context 'when the user can update the escalation status' do + before_all do + project.add_developer(user) + end + + it 'returns the issue with the escalation policy' do + expect(mutated_issue).to eq(issue) + expect(mutated_issue.escalation_status.status_name).to eq(status) + expect(result[:errors]).to be_empty + end + + it 'returns errors when issue update fails' do + issue.update_column(:author_id, nil) + + expect(result[:errors]).not_to be_empty + end + + context 'with non-incident issue is provided' do + let_it_be(:issue) { create(:issue, project: project) } + + it 'raises an error' do + expect { result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature unavailable for provided issue') + end + end + + context 'with feature disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it 'raises an error' do + expect { result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature unavailable for provided issue') + end + end + end + end + end +end diff --git a/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb b/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb index 6b8b88928d8..9b54d466681 100644 --- a/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb +++ b/spec/graphql/resolvers/clusters/agent_tokens_resolver_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Resolvers::Clusters::AgentTokensResolver do it { expect(described_class.type).to eq(Types::Clusters::AgentTokenType) } it { expect(described_class.null).to be_truthy } + it { expect(described_class.arguments.keys).to contain_exactly('status') } describe '#resolve' do let(:agent) { create(:cluster_agent) } @@ -23,6 +24,14 @@ RSpec.describe Resolvers::Clusters::AgentTokensResolver do expect(subject).to eq([matching_token2, matching_token1]) end + context 'token status is specified' do + let!(:revoked_token) { create(:cluster_agent_token, :revoked, agent: agent) } + + subject { resolve(described_class, obj: agent, ctx: ctx, args: { status: 'revoked' }) } + + it { is_expected.to contain_exactly(revoked_token) } + end + context 'user does not have permission' do let(:user) { create(:user, developer_projects: [agent.project]) } diff --git a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb index 3fcfa967452..9fe4c78f551 100644 --- a/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb +++ b/spec/graphql/resolvers/concerns/resolves_pipelines_spec.rb @@ -62,24 +62,12 @@ RSpec.describe ResolvesPipelines do context 'filtering by source' do let_it_be(:source_pipeline) { create(:ci_pipeline, project: project, source: 'web') } - context 'when `dast_view_scans` feature flag is disabled' do - before do - stub_feature_flags(dast_view_scans: false) - end - - it 'does not filter by source' do - expect(resolve_pipelines(source: 'web')).to contain_exactly(*all_pipelines, source_pipeline) - end + it 'does filter by source' do + expect(resolve_pipelines(source: 'web')).to contain_exactly(source_pipeline) end - context 'when `dast_view_scans` feature flag is enabled' do - it 'does filter by source' do - expect(resolve_pipelines(source: 'web')).to contain_exactly(source_pipeline) - end - - it 'returns all the pipelines' do - expect(resolve_pipelines).to contain_exactly(*all_pipelines, source_pipeline) - end + it 'returns all the pipelines' do + expect(resolve_pipelines).to contain_exactly(*all_pipelines, source_pipeline) end end diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index a931b0a3f77..1d0eac30a23 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -172,6 +172,28 @@ RSpec.describe Resolvers::MergeRequestsResolver do end end + context 'with draft argument' do + before do + merge_request_4.update!(title: MergeRequest.wip_title(merge_request_4.title)) + end + + context 'with draft: true argument' do + it 'takes one argument' do + result = resolve_mr(project, draft: true) + + expect(result).to contain_exactly(merge_request_4) + end + end + + context 'with draft: false argument' do + it 'takes one argument' do + result = resolve_mr(project, draft: false) + + expect(result).not_to contain_exactly(merge_request_1, merge_request_2, merge_request_3, merge_request_5, merge_request_6) + end + end + end + context 'with label argument' do let_it_be(:label) { merge_request_6.labels.first } let_it_be(:with_label) { create(:labeled_merge_request, :closed, labels: [label], **common_attrs) } diff --git a/spec/graphql/resolvers/users/groups_resolver_spec.rb b/spec/graphql/resolvers/users/groups_resolver_spec.rb index 0fdb6da5ae9..5ac7aac4898 100644 --- a/spec/graphql/resolvers/users/groups_resolver_spec.rb +++ b/spec/graphql/resolvers/users/groups_resolver_spec.rb @@ -26,14 +26,6 @@ RSpec.describe Resolvers::Users::GroupsResolver do public_maintainer_group.add_maintainer(user) end - context 'when paginatable_namespace_drop_down_for_project_creation feature flag is disabled' do - before do - stub_feature_flags(paginatable_namespace_drop_down_for_project_creation: false) - end - - it { is_expected.to be_nil } - end - context 'when resolver object is current user' do context 'when permission is :create_projects' do let(:group_arguments) { { permission_scope: :create_projects } } diff --git a/spec/graphql/resolvers/work_items/types_resolver_spec.rb b/spec/graphql/resolvers/work_items/types_resolver_spec.rb new file mode 100644 index 00000000000..b85989256b5 --- /dev/null +++ b/spec/graphql/resolvers/work_items/types_resolver_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::WorkItems::TypesResolver do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:group) { create(:group) } + + before_all do + group.add_developer(current_user) + end + + describe '#resolve' do + it 'returns all default work item types' do + result = resolve(described_class, obj: group) + + expect(result.to_a).to match(WorkItems::Type.default.order_by_name_asc) + end + end +end diff --git a/spec/graphql/types/ci/config/config_type_spec.rb b/spec/graphql/types/ci/config/config_type_spec.rb index edd190a4365..0012ae9f51f 100644 --- a/spec/graphql/types/ci/config/config_type_spec.rb +++ b/spec/graphql/types/ci/config/config_type_spec.rb @@ -11,6 +11,7 @@ RSpec.describe Types::Ci::Config::ConfigType do mergedYaml stages status + warnings ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb index e3cb56c2ad5..47d697ab8b8 100644 --- a/spec/graphql/types/ci/job_type_spec.rb +++ b/spec/graphql/types/ci/job_type_spec.rb @@ -18,6 +18,7 @@ RSpec.describe Types::Ci::JobType do created_by_tag detailedStatus duration + downstreamPipeline finished_at id manual_job diff --git a/spec/graphql/types/ci/pipeline_message_type_spec.rb b/spec/graphql/types/ci/pipeline_message_type_spec.rb new file mode 100644 index 00000000000..f5c20cd9bf6 --- /dev/null +++ b/spec/graphql/types/ci/pipeline_message_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::PipelineMessageType do + specify { expect(described_class.graphql_name).to eq('PipelineMessage') } + + it 'contains attributes related to a pipeline message' do + expected_fields = %w[ + id content + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/ci/pipeline_type_spec.rb b/spec/graphql/types/ci/pipeline_type_spec.rb index 58724524785..94d1b42da37 100644 --- a/spec/graphql/types/ci/pipeline_type_spec.rb +++ b/spec/graphql/types/ci/pipeline_type_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Types::Ci::PipelineType do coverage created_at updated_at started_at finished_at committed_at stages user retryable cancelable jobs source_job job job_artifacts downstream upstream path project active user_permissions warnings commit commit_path uses_needs - test_report_summary test_suite ref + test_report_summary test_suite ref ref_path warning_messages ] if Gitlab.ee? diff --git a/spec/graphql/types/ci/runner_type_spec.rb b/spec/graphql/types/ci/runner_type_spec.rb index cf8650a4a03..43d8b585d6b 100644 --- a/spec/graphql/types/ci/runner_type_spec.rb +++ b/spec/graphql/types/ci/runner_type_spec.rb @@ -9,9 +9,9 @@ RSpec.describe GitlabSchema.types['CiRunner'] do it 'contains attributes related to a runner' do expected_fields = %w[ - id description contacted_at maximum_timeout access_level active status + id description created_at contacted_at maximum_timeout access_level active status version short_sha revision locked run_untagged ip_address runner_type tag_list - project_count job_count admin_url user_permissions + project_count job_count admin_url edit_admin_url user_permissions executor_name ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/clusters/agent_token_status_enum_spec.rb b/spec/graphql/types/clusters/agent_token_status_enum_spec.rb new file mode 100644 index 00000000000..071e4050cfb --- /dev/null +++ b/spec/graphql/types/clusters/agent_token_status_enum_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Clusters::AgentTokenStatusEnum do + it { expect(described_class.graphql_name).to eq('AgentTokenStatus') } + it { expect(described_class.values.keys).to match_array(Clusters::AgentToken.statuses.keys.map(&:upcase)) } +end diff --git a/spec/graphql/types/clusters/agent_token_type_spec.rb b/spec/graphql/types/clusters/agent_token_type_spec.rb index c872d201fd9..3f0720cb4b5 100644 --- a/spec/graphql/types/clusters/agent_token_type_spec.rb +++ b/spec/graphql/types/clusters/agent_token_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ClusterAgentToken'] do - let(:fields) { %i[cluster_agent created_at created_by_user description id last_used_at name] } + let(:fields) { %i[cluster_agent created_at created_by_user description id last_used_at name status] } it { expect(described_class.graphql_name).to eq('ClusterAgentToken') } diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb index 2f74ce81761..c1d838c3117 100644 --- a/spec/graphql/types/commit_type_spec.rb +++ b/spec/graphql/types/commit_type_spec.rb @@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Commit'] do it 'contains attributes related to commit' do expect(described_class).to have_graphql_fields( :id, :sha, :short_id, :title, :full_title, :full_title_html, :description, :description_html, :message, :title_html, :authored_date, - :author_name, :author_gravatar, :author, :web_url, :web_path, + :author_name, :author_email, :author_gravatar, :author, :web_url, :web_path, :pipelines, :signature_html ) end diff --git a/spec/graphql/types/group_member_relation_enum_spec.rb b/spec/graphql/types/group_member_relation_enum_spec.rb index 315809ef75e..89ee8c574c4 100644 --- a/spec/graphql/types/group_member_relation_enum_spec.rb +++ b/spec/graphql/types/group_member_relation_enum_spec.rb @@ -6,6 +6,6 @@ RSpec.describe Types::GroupMemberRelationEnum do specify { expect(described_class.graphql_name).to eq('GroupMemberRelation') } it 'exposes all the existing group member relation type values' do - expect(described_class.values.keys).to contain_exactly('DIRECT', 'INHERITED', 'DESCENDANTS') + expect(described_class.values.keys).to contain_exactly('DIRECT', 'INHERITED', 'DESCENDANTS', 'SHARED_FROM_GROUPS') end end diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb index dca2c930eea..0ba322a100a 100644 --- a/spec/graphql/types/group_type_spec.rb +++ b/spec/graphql/types/group_type_spec.rb @@ -22,7 +22,7 @@ RSpec.describe GitlabSchema.types['Group'] do dependency_proxy_blobs dependency_proxy_image_count dependency_proxy_blob_count dependency_proxy_total_size dependency_proxy_image_prefix dependency_proxy_image_ttl_policy - shared_runners_setting timelogs organizations contacts + shared_runners_setting timelogs organizations contacts work_item_types ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/incident_management/escalation_status_enum_spec.rb b/spec/graphql/types/incident_management/escalation_status_enum_spec.rb new file mode 100644 index 00000000000..b39d4d9324e --- /dev/null +++ b/spec/graphql/types/incident_management/escalation_status_enum_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['IssueEscalationStatus'] do + specify { expect(described_class.graphql_name).to eq('IssueEscalationStatus') } + + describe 'statuses' do + using RSpec::Parameterized::TableSyntax + + where(:status_name, :status_value) do + 'TRIGGERED' | :triggered + 'ACKNOWLEDGED' | :acknowledged + 'RESOLVED' | :resolved + 'IGNORED' | :ignored + 'INVALID' | nil + end + + with_them do + it 'exposes a status with the correct value' do + expect(described_class.values[status_name]&.value).to eq(status_value) + end + end + end +end diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb index 1b8bf007a73..1d4590cbb4e 100644 --- a/spec/graphql/types/issue_type_spec.rb +++ b/spec/graphql/types/issue_type_spec.rb @@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['Issue'] do confidential hidden discussion_locked upvotes downvotes merge_requests_count user_notes_count user_discussions_count web_path web_url relative_position emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status design_collection alert_management_alert severity current_user_todos moved moved_to - create_note_email timelogs project_id customer_relations_contacts] + create_note_email timelogs project_id customer_relations_contacts escalation_status] fields.each do |field_name| expect(described_class).to have_graphql_field(field_name) @@ -257,4 +257,49 @@ RSpec.describe GitlabSchema.types['Issue'] do end end end + + describe 'escalation_status' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue, reload: true) { create(:issue, project: project) } + + let(:execute) { GitlabSchema.execute(query, context: { current_user: user }).as_json } + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + issue(iid: "#{issue.iid}") { + escalationStatus + } + } + } + ) + end + + subject(:status) { execute.dig('data', 'project', 'issue', 'escalationStatus') } + + it { is_expected.to be_nil } + + context 'for an incident' do + before do + issue.update!(issue_type: Issue.issue_types[:incident]) + end + + it { is_expected.to be_nil } + + context 'with an escalation status record' do + let!(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: issue) } + + it { is_expected.to eq(escalation_status.status_name.to_s.upcase) } + + context 'with feature disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it { is_expected.to be_nil } + end + end + end + end end diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index b17b7c32289..5ab8845246a 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -33,7 +33,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do total_time_spent human_time_estimate human_total_time_spent reference author merged_at commit_count current_user_todos conflicts auto_merge_enabled approved_by source_branch_protected default_merge_commit_message_with_description squash_on_merge available_auto_merge_strategies - has_ci mergeable commits_without_merge_commits squash security_auto_fix default_squash_commit_message + has_ci mergeable commits commits_without_merge_commits squash security_auto_fix default_squash_commit_message auto_merge_strategy merge_user ] @@ -133,4 +133,28 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do end end end + + describe '#merge_user' do + let_it_be(:project) { create(:project, :public) } + + context 'when MR is merged' do + let(:merge_request) { create(:merge_request, :with_merged_metrics, target_project: project, source_project: project) } + + it 'is not nil' do + value = resolve_field(:merge_user, merge_request) + + expect(value).not_to be_nil + end + end + + context 'when MR is set to merge when pipeline succeeds' do + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds, target_project: project, source_project: project) } + + it 'is not nil' do + value = resolve_field(:merge_user, merge_request) + + expect(value).not_to be_nil + end + end + end end diff --git a/spec/graphql/types/mutation_type_spec.rb b/spec/graphql/types/mutation_type_spec.rb index 95d835c88cf..1fc46f2d511 100644 --- a/spec/graphql/types/mutation_type_spec.rb +++ b/spec/graphql/types/mutation_type_spec.rb @@ -7,6 +7,14 @@ RSpec.describe Types::MutationType do expect(described_class).to have_graphql_mutation(Mutations::MergeRequests::SetDraft) end + describe 'deprecated mutations' do + describe 'clusterAgentTokenDelete' do + let(:field) { get_field('clusterAgentTokenDelete') } + + it { expect(field.deprecation_reason).to eq('Tokens must be revoked with ClusterAgentTokenRevoke. Deprecated in 14.7.') } + end + end + def get_field(name) described_class.fields[GraphqlHelpers.fieldnamerize(name)] end diff --git a/spec/graphql/types/packages/package_details_type_spec.rb b/spec/graphql/types/packages/package_details_type_spec.rb index f0b684d6b07..ceeb000ff85 100644 --- a/spec/graphql/types/packages/package_details_type_spec.rb +++ b/spec/graphql/types/packages/package_details_type_spec.rb @@ -5,7 +5,10 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['PackageDetailsType'] do it 'includes all the package fields' do expected_fields = %w[ - id name version created_at updated_at package_type tags project pipelines versions package_files dependency_links + id name version created_at updated_at package_type tags project + pipelines versions package_files dependency_links + npm_url maven_url conan_url nuget_url pypi_url pypi_setup_url + composer_url composer_config_repository_url ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index adf5507571b..cd216232569 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -34,7 +34,7 @@ RSpec.describe GitlabSchema.types['Project'] do container_repositories container_repositories_count pipeline_analytics squash_read_only sast_ci_configuration cluster_agent cluster_agents agent_configurations - ci_template timelogs merge_commit_template squash_commit_template + ci_template timelogs merge_commit_template squash_commit_template work_item_types ] expect(described_class).to include_graphql_fields(*expected_fields) @@ -289,6 +289,7 @@ RSpec.describe GitlabSchema.types['Project'] do :source_branches, :target_branches, :state, + :draft, :labels, :before, :after, diff --git a/spec/graphql/types/projects/service_type_spec.rb b/spec/graphql/types/projects/service_type_spec.rb index cb09f1ca6cc..0bffdfd629d 100644 --- a/spec/graphql/types/projects/service_type_spec.rb +++ b/spec/graphql/types/projects/service_type_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Types::Projects::ServiceType do describe ".resolve_type" do it 'resolves the corresponding type for objects' do expect(described_class.resolve_type(build(:jira_integration), {})).to eq(Types::Projects::Services::JiraServiceType) - expect(described_class.resolve_type(build(:service), {})).to eq(Types::Projects::Services::BaseServiceType) + expect(described_class.resolve_type(build(:integration), {})).to eq(Types::Projects::Services::BaseServiceType) expect(described_class.resolve_type(build(:drone_ci_integration), {})).to eq(Types::Projects::Services::BaseServiceType) expect(described_class.resolve_type(build(:custom_issue_tracker_integration), {})).to eq(Types::Projects::Services::BaseServiceType) end diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb index 21bc88e34c0..8d845e5d814 100644 --- a/spec/graphql/types/repository/blob_type_spec.rb +++ b/spec/graphql/types/repository/blob_type_spec.rb @@ -21,15 +21,21 @@ RSpec.describe Types::Repository::BlobType do :file_type, :edit_blob_path, :stored_externally, + :external_storage, :raw_path, :replace_path, :pipeline_editor_path, + :find_file_path, + :blame_path, + :history_path, + :permalink_path, :code_owners, :simple_viewer, :rich_viewer, :plain_data, :can_modify_blob, :can_current_user_push_to_branch, + :archived, :ide_edit_path, :external_storage_url, :fork_and_edit_path, diff --git a/spec/helpers/admin/background_migrations_helper_spec.rb b/spec/helpers/admin/background_migrations_helper_spec.rb index 8880a00755b..9c1bb0b9c55 100644 --- a/spec/helpers/admin/background_migrations_helper_spec.rb +++ b/spec/helpers/admin/background_migrations_helper_spec.rb @@ -3,22 +3,22 @@ require "spec_helper" RSpec.describe Admin::BackgroundMigrationsHelper do - describe '#batched_migration_status_badge_class_name' do + describe '#batched_migration_status_badge_variant' do using RSpec::Parameterized::TableSyntax - where(:status, :class_name) do - :active | 'badge-info' - :paused | 'badge-warning' - :failed | 'badge-danger' - :finished | 'badge-success' + where(:status, :variant) do + :active | :info + :paused | :warning + :failed | :danger + :finished | :success end - subject { helper.batched_migration_status_badge_class_name(migration) } + subject { helper.batched_migration_status_badge_variant(migration) } with_them do let(:migration) { build(:batched_background_migration, status: status) } - it { is_expected.to eq(class_name) } + it { is_expected.to eq(variant) } end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 7390b9b3f58..8c2b4b16075 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -477,4 +477,44 @@ RSpec.describe ApplicationHelper do expect(helper).to have_received(:form_for).with(user, expected_options) end end + + describe '#page_class' do + context 'when logged_out_marketing_header experiment is enabled' do + let_it_be(:expected_class) { 'logged-out-marketing-header-candidate' } + + let(:current_user) { nil } + let(:variant) { :candidate } + + subject do + helper.page_class.flatten + end + + before do + stub_experiments(logged_out_marketing_header: variant) + allow(helper).to receive(:current_user) { current_user } + end + + context 'when candidate' do + it { is_expected.to include(expected_class) } + end + + context 'when candidate (:trial_focused variant)' do + let(:variant) { :trial_focused } + + it { is_expected.to include(expected_class) } + end + + context 'when control' do + let(:variant) { :control } + + it { is_expected.not_to include(expected_class) } + end + + context 'when a user is logged in' do + let(:current_user) { create(:user) } + + it { is_expected.not_to include(expected_class) } + end + end + end end diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 3c2ac954fe5..e722f301522 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -253,6 +253,32 @@ RSpec.describe ApplicationSettingsHelper do end end + describe '.registration_features_can_be_prompted?' do + subject { helper.registration_features_can_be_prompted? } + + before do + if Gitlab.ee? + allow(License).to receive(:current).and_return(nil) + end + end + + context 'when service ping is enabled' do + before do + stub_application_setting(usage_ping_enabled: true) + end + + it { is_expected.to be_falsey } + end + + context 'when service ping is disabled' do + before do + stub_application_setting(usage_ping_enabled: false) + end + + it { is_expected.to be_truthy } + end + end + describe '#sidekiq_job_limiter_modes_for_select' do subject { helper.sidekiq_job_limiter_modes_for_select } diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb index b481c214ca1..4bb09699db4 100644 --- a/spec/helpers/auth_helper_spec.rb +++ b/spec/helpers/auth_helper_spec.rb @@ -312,12 +312,6 @@ RSpec.describe AuthHelper do it { is_expected.to be_truthy } end - context 'when current user is set' do - let(:user) { instance_double('User') } - - it { is_expected.to eq(false) } - end - context 'when no key is set' do before do stub_config(extra: {}) diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb index 4f060a0ae3b..1083faa5e19 100644 --- a/spec/helpers/auto_devops_helper_spec.rb +++ b/spec/helpers/auto_devops_helper_spec.rb @@ -86,7 +86,7 @@ RSpec.describe AutoDevopsHelper do context 'when another service is enabled' do before do - create(:service, project: project, category: :ci, active: true) + create(:integration, project: project, category: :ci, active: true) end it { is_expected.to eq(false) } diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb index 5601ab2df2a..851e13d908f 100644 --- a/spec/helpers/button_helper_spec.rb +++ b/spec/helpers/button_helper_spec.rb @@ -167,6 +167,7 @@ RSpec.describe ButtonHelper do expect(element.attr('class')).to eq('btn btn-clipboard btn-transparent') expect(element.attr('type')).to eq('button') expect(element.attr('aria-label')).to eq('Copy') + expect(element.attr('aria-live')).to eq('polite') expect(element.attr('data-toggle')).to eq('tooltip') expect(element.attr('data-placement')).to eq('bottom') expect(element.attr('data-container')).to eq('body') diff --git a/spec/helpers/ci/jobs_helper_spec.rb b/spec/helpers/ci/jobs_helper_spec.rb index e5ef362e91b..489d9d3fcee 100644 --- a/spec/helpers/ci/jobs_helper_spec.rb +++ b/spec/helpers/ci/jobs_helper_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe Ci::JobsHelper do describe 'jobs data' do let(:project) { create(:project, :repository) } - let(:bridge) { create(:ci_bridge, status: :pending) } + let(:bridge) { create(:ci_bridge) } - subject(:bridge_data) { helper.bridge_data(bridge) } + subject(:bridge_data) { helper.bridge_data(bridge, project) } before do allow(helper) @@ -17,8 +17,10 @@ RSpec.describe Ci::JobsHelper do it 'returns bridge data' do expect(bridge_data).to eq({ - "build_name" => bridge.name, - "empty-state-illustration-path" => '/path/to/illustration' + "build_id" => bridge.id, + "empty-state-illustration-path" => '/path/to/illustration', + "pipeline_iid" => bridge.pipeline.iid, + "project_full_path" => project.full_path }) end end diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index 874937bc4ce..b15569f03c7 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -46,6 +46,7 @@ RSpec.describe Ci::PipelineEditorHelper do "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), + "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => graphql_etag_pipeline_sha_path(project.commit.sha), @@ -72,6 +73,7 @@ RSpec.describe Ci::PipelineEditorHelper do "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), + "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => '', diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index 173a0d3ab3c..832b4da0e20 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Ci::RunnersHelper do describe '#runner_status_icon', :clean_gitlab_redis_cache do it "returns - not contacted yet" do runner = create(:ci_runner) - expect(helper.runner_status_icon(runner)).to include("not connected yet") + expect(helper.runner_status_icon(runner)).to include("not contacted yet") end it "returns offline text" do @@ -79,12 +79,7 @@ RSpec.describe Ci::RunnersHelper do it 'returns the data in format' do expect(helper.admin_runners_data_attributes).to eq({ runner_install_help_page: 'https://docs.gitlab.com/runner/install/', - registration_token: Gitlab::CurrentSettings.runners_registration_token, - active_runners_count: '0', - all_runners_count: '2', - instance_runners_count: '1', - group_runners_count: '0', - project_runners_count: '1' + registration_token: Gitlab::CurrentSettings.runners_registration_token }) end end diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb index 49937a3b53a..8e5f38cd95a 100644 --- a/spec/helpers/environment_helper_spec.rb +++ b/spec/helpers/environment_helper_spec.rb @@ -21,6 +21,16 @@ RSpec.describe EnvironmentHelper do expect(html).to have_css('a.ci-status.ci-success') end end + + context 'for a blocked deployment' do + subject { helper.render_deployment_status(deployment) } + + let(:deployment) { build(:deployment, :blocked) } + + it 'indicates the status' do + expect(subject).to have_text('blocked') + end + end end describe '#environments_detail_data_json' do diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb index aef240db5b8..38f06b19b94 100644 --- a/spec/helpers/environments_helper_spec.rb +++ b/spec/helpers/environments_helper_spec.rb @@ -18,7 +18,7 @@ RSpec.describe EnvironmentsHelper do it 'returns data' do expect(metrics_data).to include( - 'settings_path' => edit_project_service_path(project, 'prometheus'), + 'settings_path' => edit_project_integration_path(project, 'prometheus'), 'clusters_path' => project_clusters_path(project), 'metrics_dashboard_base_path' => environment_metrics_path(environment), 'current_environment_name' => environment.name, diff --git a/spec/helpers/groups/crm_settings_helper_spec.rb b/spec/helpers/groups/crm_settings_helper_spec.rb new file mode 100644 index 00000000000..6376cabda3a --- /dev/null +++ b/spec/helpers/groups/crm_settings_helper_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::CrmSettingsHelper do + let_it_be(:group) { create(:group) } + + describe '#crm_feature_flag_enabled?' do + subject do + helper.crm_feature_flag_enabled?(group) + end + + context 'when feature flag is enabled' do + it { is_expected.to be_truthy } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it { is_expected.to be_falsy } + end + end +end diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb index 3b23d705790..bac73db5dd4 100644 --- a/spec/helpers/hooks_helper_spec.rb +++ b/spec/helpers/hooks_helper_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe HooksHelper do let(:project) { create(:project) } let(:project_hook) { create(:project_hook, project: project) } + let(:service_hook) { create(:service_hook, integration: create(:drone_ci_integration)) } let(:system_hook) { create(:system_hook) } describe '#link_to_test_hook' do @@ -31,6 +32,15 @@ RSpec.describe HooksHelper do end end + context 'with a service hook' do + let(:web_hook_log) { create(:web_hook_log, web_hook: service_hook) } + + it 'returns project-namespaced link' do + expect(helper.hook_log_path(project_hook, web_hook_log)) + .to eq(web_hook_log.present.details_path) + end + end + context 'with a system hook' do let(:web_hook_log) { create(:web_hook_log, web_hook: system_hook) } diff --git a/spec/helpers/integrations_helper_spec.rb b/spec/helpers/integrations_helper_spec.rb index 3a7d4d12513..38ce17e34ba 100644 --- a/spec/helpers/integrations_helper_spec.rb +++ b/spec/helpers/integrations_helper_spec.rb @@ -20,6 +20,12 @@ RSpec.describe IntegrationsHelper do end describe '#integration_form_data' do + before do + allow(helper).to receive_messages( + request: double(referer: '/services') + ) + end + let(:fields) do [ :id, @@ -39,7 +45,9 @@ RSpec.describe IntegrationsHelper do :cancel_path, :can_test, :test_path, - :reset_path + :reset_path, + :form_path, + :redirect_to ] end @@ -61,6 +69,10 @@ RSpec.describe IntegrationsHelper do specify do expect(subject[:reset_path]).to eq(helper.scoped_reset_integration_path(integration)) end + + specify do + expect(subject[:redirect_to]).to eq('/services') + end end context 'Jira service' do @@ -70,6 +82,20 @@ RSpec.describe IntegrationsHelper do end end + describe '#integration_overrides_data' do + let(:integration) { build_stubbed(:jira_integration) } + let(:fields) do + [ + edit_path: edit_admin_application_settings_integration_path(integration), + overrides_path: overrides_admin_application_settings_integration_path(integration, format: :json) + ] + end + + subject { helper.integration_overrides_data(integration) } + + it { is_expected.to include(*fields) } + end + describe '#scoped_reset_integration_path' do let(:integration) { build_stubbed(:jira_integration) } let(:group) { nil } diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index ad0ea6911f1..065ac526ae4 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -9,7 +9,7 @@ RSpec.describe IssuesHelper do describe '#work_item_type_icon' do it 'returns icon of all standard base types' do - WorkItem::Type.base_types.each do |type| + WorkItems::Type.base_types.each do |type| expect(work_item_type_icon(type[0])).to eq "issue-type-#{type[0].to_s.dasherize}" end end @@ -246,27 +246,6 @@ RSpec.describe IssuesHelper do end end - describe '#use_startup_call' do - it 'returns false when a query param is present' do - allow(controller.request).to receive(:query_parameters).and_return({ foo: 'bar' }) - - expect(helper.use_startup_call?).to eq(false) - end - - it 'returns false when user has stored sort preference' do - controller.instance_variable_set(:@sort, 'updated_asc') - - expect(helper.use_startup_call?).to eq(false) - end - - it 'returns true when request.query_parameters is empty with default sorting preference' do - controller.instance_variable_set(:@sort, 'created_date') - allow(controller.request).to receive(:query_parameters).and_return({}) - - expect(helper.use_startup_call?).to eq(true) - end - end - describe '#issue_header_actions_data' do let(:current_user) { create(:user) } diff --git a/spec/helpers/learn_gitlab_helper_spec.rb b/spec/helpers/learn_gitlab_helper_spec.rb index 9d13fc65de7..ffc2bb31b8f 100644 --- a/spec/helpers/learn_gitlab_helper_spec.rb +++ b/spec/helpers/learn_gitlab_helper_spec.rb @@ -176,6 +176,19 @@ RSpec.describe LearnGitlabHelper do ) }) end + + it 'calls experiment with expected context & options' do + allow(helper).to receive(:current_user).and_return(user) + + expect(helper).to receive(:experiment).with( + :change_continuous_onboarding_link_urls, + namespace: namespace, + actor: user, + sticky_to: namespace + ) + + learn_gitlab_data + end end end end diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb index 6eb560e3f5c..00aa0fd1cba 100644 --- a/spec/helpers/namespaces_helper_spec.rb +++ b/spec/helpers/namespaces_helper_spec.rb @@ -188,44 +188,6 @@ RSpec.describe NamespacesHelper do helper.namespaces_options end end - - describe 'include_groups_with_developer_maintainer_access parameter' do - context 'when DEVELOPER_MAINTAINER_PROJECT_ACCESS is set for a project' do - let!(:admin_project_creation_level) { ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS } - - it 'returns groups where user is a developer' do - allow(helper).to receive(:current_user).and_return(user) - stub_application_setting(default_project_creation: ::Gitlab::Access::MAINTAINER_PROJECT_ACCESS) - admin_group.add_user(user, GroupMember::DEVELOPER) - - options = helper.namespaces_options_with_developer_maintainer_access - - expect(options).to include(admin_group.name) - expect(options).not_to include(subgroup1.name) - expect(options).to include(subgroup2.name) - expect(options).not_to include(subgroup3.name) - expect(options).to include(user_group.name) - expect(options).to include(user.name) - end - end - - context 'when DEVELOPER_MAINTAINER_PROJECT_ACCESS is set globally' do - it 'return groups where default is not overridden' do - allow(helper).to receive(:current_user).and_return(user) - stub_application_setting(default_project_creation: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) - admin_group.add_user(user, GroupMember::DEVELOPER) - - options = helper.namespaces_options_with_developer_maintainer_access - - expect(options).to include(admin_group.name) - expect(options).to include(subgroup1.name) - expect(options).to include(subgroup2.name) - expect(options).not_to include(subgroup3.name) - expect(options).to include(user_group.name) - expect(options).to include(user.name) - end - end - end end describe '#cascading_namespace_settings_popover_data' do diff --git a/spec/helpers/nav/top_nav_helper_spec.rb b/spec/helpers/nav/top_nav_helper_spec.rb index 10bd45e3189..ef6a6827826 100644 --- a/spec/helpers/nav/top_nav_helper_spec.rb +++ b/spec/helpers/nav/top_nav_helper_spec.rb @@ -20,7 +20,6 @@ RSpec.describe Nav::TopNavHelper do let(:current_group) { nil } let(:with_current_settings_admin_mode) { false } let(:with_header_link_admin_mode) { false } - let(:with_sherlock_enabled) { false } let(:with_projects) { false } let(:with_groups) { false } let(:with_milestones) { false } @@ -34,7 +33,6 @@ RSpec.describe Nav::TopNavHelper do before do allow(Gitlab::CurrentSettings).to receive(:admin_mode) { with_current_settings_admin_mode } allow(helper).to receive(:header_link?).with(:admin_mode) { with_header_link_admin_mode } - allow(Gitlab::Sherlock).to receive(:enabled?) { with_sherlock_enabled } # Defaulting all `dashboard_nav_link?` calls to false ensures the EE-specific behavior # is not enabled in this CE spec @@ -434,27 +432,6 @@ RSpec.describe Nav::TopNavHelper do expect(subject[:shortcuts]).to eq([expected_shortcuts]) end end - - context 'when sherlock is enabled' do - let(:with_sherlock_enabled) { true } - - before do - # Note: We have to mock the sherlock route because the route is conditional on - # sherlock being enabled, but it parsed at Rails load time and can't be overridden - # in a spec. - allow(helper).to receive(:sherlock_transactions_path) { '/fake_sherlock_path' } - end - - it 'has sherlock as last :secondary item' do - expected_sherlock_item = ::Gitlab::Nav::TopNavMenuItem.build( - id: 'sherlock', - title: 'Sherlock Transactions', - icon: 'admin', - href: '/fake_sherlock_path' - ) - expect(subject[:secondary].last).to eq(expected_sherlock_item) - end - end end context 'when current_user is admin' do diff --git a/spec/helpers/operations_helper_spec.rb b/spec/helpers/operations_helper_spec.rb index 1864f9fad15..857771ebba6 100644 --- a/spec/helpers/operations_helper_spec.rb +++ b/spec/helpers/operations_helper_spec.rb @@ -32,7 +32,7 @@ RSpec.describe OperationsHelper do expect(subject).to eq( 'alerts_setup_url' => help_page_path('operations/incident_management/integrations.md', anchor: 'configuration'), 'alerts_usage_url' => project_alert_management_index_path(project), - 'prometheus_form_path' => project_service_path(project, prometheus_integration), + 'prometheus_form_path' => project_integration_path(project, prometheus_integration), 'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(project), 'prometheus_authorization_key' => nil, 'prometheus_api_url' => nil, diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index 06c6cccd488..8b3c8411fbd 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -219,45 +219,4 @@ RSpec.describe PackagesHelper do it { is_expected.to eq(expected_result) } end end - - describe '#package_details_data' do - let_it_be(:package) { create(:package) } - - let(:expected_result) do - { - package_id: package.id, - can_delete: 'true', - project_name: project.name, - group_list_url: '' - } - end - - before do - allow(helper).to receive(:current_user) { project.owner } - allow(helper).to receive(:can?) { true } - end - - context 'in a project without a group' do - it 'populates presenter data' do - result = helper.package_details_data(project, package) - - expect(result).to match(hash_including(expected_result)) - end - end - - context 'in a project with a group' do - let_it_be(:group) { create(:group) } - let_it_be(:project_with_group) { create(:project, group: group) } - - it 'populates presenter data' do - result = helper.package_details_data(project_with_group, package) - expected = expected_result.merge({ - group_list_url: group_packages_path(project_with_group.group), - project_name: project_with_group.name - }) - - expect(result).to match(hash_including(expected)) - end - end - end end diff --git a/spec/helpers/projects/cluster_agents_helper_spec.rb b/spec/helpers/projects/cluster_agents_helper_spec.rb index 2935a74586b..632544797ee 100644 --- a/spec/helpers/projects/cluster_agents_helper_spec.rb +++ b/spec/helpers/projects/cluster_agents_helper_spec.rb @@ -17,5 +17,10 @@ RSpec.describe Projects::ClusterAgentsHelper do it 'returns project path' do expect(subject[:project_path]).to eq(project.full_path) end + + it 'returns string contants' do + expect(subject[:activity_empty_state_image]).to be_kind_of(String) + expect(subject[:empty_state_svg_path]).to be_kind_of(String) + end end end diff --git a/spec/helpers/projects/issues/service_desk_helper_spec.rb b/spec/helpers/projects/issues/service_desk_helper_spec.rb deleted file mode 100644 index 05766ee13c6..00000000000 --- a/spec/helpers/projects/issues/service_desk_helper_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::Issues::ServiceDeskHelper do - let_it_be(:project) { create(:project, :public, service_desk_enabled: true) } - - let(:user) { build_stubbed(:user) } - let(:current_user) { user } - - describe '#service_desk_meta' do - subject { helper.service_desk_meta(project) } - - context "when service desk is supported and user can edit project settings" do - before do - allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true) - allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true) - allow(helper).to receive(:current_user).and_return(user) - allow(helper).to receive(:can?).with(current_user, :admin_project, project).and_return(true) - end - - it { - is_expected.to eq({ - is_service_desk_supported: true, - is_service_desk_enabled: true, - can_edit_project_settings: true, - service_desk_address: project.service_desk_address, - service_desk_help_page: help_page_path('user/project/service_desk'), - edit_project_page: edit_project_path(project), - svg_path: ActionController::Base.helpers.image_path('illustrations/service_desk_empty.svg') - }) - } - end - - context "when service desk is not supported and user cannot edit project settings" do - before do - allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(false) - allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(false) - allow(helper).to receive(:current_user).and_return(user) - allow(helper).to receive(:can?).with(current_user, :admin_project, project).and_return(false) - end - - it { - is_expected.to eq({ - is_service_desk_supported: false, - is_service_desk_enabled: false, - can_edit_project_settings: false, - incoming_email_help_page: help_page_path('administration/incoming_email', anchor: 'set-it-up'), - svg_path: ActionController::Base.helpers.image_path('illustrations/service-desk-setup.svg') - }) - } - end - end -end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 17dcbab09bb..40cfdafc9ac 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe SearchHelper do include MarkupHelper + include BadgesHelper # Override simple_sanitize for our testing purposes def simple_sanitize(str) @@ -640,7 +641,7 @@ RSpec.describe SearchHelper do } }, { - title: _('Last updated'), + title: _('Updated date'), sortable: true, sortParam: { asc: 'updated_asc', diff --git a/spec/helpers/snippets_helper_spec.rb b/spec/helpers/snippets_helper_spec.rb index 12d791d8710..913be164a00 100644 --- a/spec/helpers/snippets_helper_spec.rb +++ b/spec/helpers/snippets_helper_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe SnippetsHelper do include Gitlab::Routing include IconsHelper + include BadgesHelper let_it_be(:public_personal_snippet) { create(:personal_snippet, :public, :repository) } let_it_be(:public_project_snippet) { create(:project_snippet, :public, :repository) } @@ -72,7 +73,7 @@ RSpec.describe SnippetsHelper do let(:visibility) { :private } it 'returns the snippet badge' do - expect(subject).to eq "<span class=\"badge badge-gray\">#{sprite_icon('lock', size: 14, css_class: 'gl-vertical-align-middle')} private</span>" + expect(subject).to eq gl_badge_tag('private', icon: 'lock') end end diff --git a/spec/helpers/ssh_keys_helper_spec.rb b/spec/helpers/ssh_keys_helper_spec.rb new file mode 100644 index 00000000000..1aa604f19be --- /dev/null +++ b/spec/helpers/ssh_keys_helper_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SshKeysHelper do + describe '#ssh_key_allowed_algorithms' do + it 'returns string with the names of allowed algorithms that are quoted and joined by commas' do + allowed_algorithms = Gitlab::CurrentSettings.allowed_key_types.flat_map do |ssh_key_type_name| + Gitlab::SSHPublicKey.supported_algorithms_for_name(ssh_key_type_name) + end + + quoted_allowed_algorithms = allowed_algorithms.map { |name| "'#{name}'" } + + expected_string = Gitlab::Utils.to_exclusive_sentence(quoted_allowed_algorithms) + + expect(ssh_key_allowed_algorithms).to eq(expected_string) + end + + it 'returns only allowed algorithms' do + expect(ssh_key_allowed_algorithms).to match('ed25519') + stub_application_setting(ed25519_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE) + expect(ssh_key_allowed_algorithms).not_to match('ed25519') + end + end +end diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb index bc25a2fcdfc..1a0ecd5d903 100644 --- a/spec/helpers/tree_helper_spec.rb +++ b/spec/helpers/tree_helper_spec.rb @@ -84,14 +84,21 @@ RSpec.describe TreeHelper do describe '#web_ide_button_data' do let(:blob) { project.repository.blob_at('refs/heads/master', @path) } + let_it_be(:user_preferences_gitpod_path) { '/-/profile/preferences#user_gitpod_enabled' } + let_it_be(:user_profile_enable_gitpod_path) { '/-/profile?user%5Bgitpod_enabled%5D=true' } + before do @path = '' @project = project @ref = sha - allow(helper).to receive(:current_user).and_return(nil) - allow(helper).to receive(:can_collaborate_with_project?).and_return(true) - allow(helper).to receive(:can?).and_return(true) + allow(helper).to receive_messages( + current_user: nil, + can_collaborate_with_project?: true, + can?: true, + user_preferences_gitpod_path: user_preferences_gitpod_path, + user_profile_enable_gitpod_path: user_profile_enable_gitpod_path + ) end subject { helper.web_ide_button_data(blob: blob) } @@ -112,7 +119,10 @@ RSpec.describe TreeHelper do edit_url: '', web_ide_url: "/-/ide/project/#{project.full_path}/edit/#{sha}", - gitpod_url: '' + + gitpod_url: '', + user_preferences_gitpod_path: user_preferences_gitpod_path, + user_profile_enable_gitpod_path: user_profile_enable_gitpod_path ) end diff --git a/spec/helpers/version_check_helper_spec.rb b/spec/helpers/version_check_helper_spec.rb index bd52eda8a65..959c4a94a78 100644 --- a/spec/helpers/version_check_helper_spec.rb +++ b/spec/helpers/version_check_helper_spec.rb @@ -3,33 +3,34 @@ require 'spec_helper' RSpec.describe VersionCheckHelper do - describe '#version_status_badge' do - it 'returns nil if not dev environment and not enabled' do - stub_rails_env('development') - allow(Gitlab::CurrentSettings.current_application_settings).to receive(:version_check_enabled) { false } + let_it_be(:user) { create(:user) } - expect(helper.version_status_badge).to be(nil) - end - - context 'when production and enabled' do - before do - stub_rails_env('production') - allow(Gitlab::CurrentSettings.current_application_settings).to receive(:version_check_enabled) { true } - allow(VersionCheck).to receive(:image_url) { 'https://version.host.com/check.svg?gitlab_info=xxx' } + describe '#show_version_check?' do + describe 'return conditions' do + where(:enabled, :consent, :is_admin, :result) do + [ + [false, false, false, false], + [false, false, true, false], + [false, true, false, false], + [false, true, true, false], + [true, false, false, false], + [true, false, true, true], + [true, true, false, false], + [true, true, true, false] + ] end - it 'returns an image tag' do - expect(helper.version_status_badge).to start_with('<img') - end - - it 'has a js prefixed css class' do - expect(helper.version_status_badge) - .to match(/class="js-version-status-badge lazy"/) - end + with_them do + before do + stub_application_setting(version_check_enabled: enabled) + allow(User).to receive(:single_user).and_return(double(user, requires_usage_stats_consent?: consent)) + allow(helper).to receive(:current_user).and_return(user) + allow(user).to receive(:can_read_all_resources?).and_return(is_admin) + end - it 'has a VersionCheck image_url as the src' do - expect(helper.version_status_badge) - .to include(%{src="https://version.host.com/check.svg?gitlab_info=xxx"}) + it 'returns correct results' do + expect(helper.show_version_check?).to eq result + end end end end diff --git a/spec/initializers/doorkeeper_spec.rb b/spec/initializers/doorkeeper_spec.rb index 164225a00b2..56e0a22d59f 100644 --- a/spec/initializers/doorkeeper_spec.rb +++ b/spec/initializers/doorkeeper_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Doorkeeper.configuration do before do allow(controller).to receive(:current_user).and_return(current_user) allow(controller).to receive(:session).and_return({}) - allow(controller).to receive(:request).and_return(OpenStruct.new(fullpath: '/return-path')) + allow(controller).to receive(:request).and_return(double('request', fullpath: '/return-path')) allow(controller).to receive(:redirect_to) allow(controller).to receive(:new_user_session_url).and_return('/login') end diff --git a/spec/initializers/session_store_spec.rb b/spec/initializers/session_store_spec.rb index db90b335dc9..a94ce327a92 100644 --- a/spec/initializers/session_store_spec.rb +++ b/spec/initializers/session_store_spec.rb @@ -10,40 +10,10 @@ RSpec.describe 'Session initializer for GitLab' do end describe 'config#session_store' do - context 'when the GITLAB_USE_REDIS_SESSIONS_STORE env is not set' do - before do - stub_env('GITLAB_USE_REDIS_SESSIONS_STORE', nil) - end + it 'initialized as a redis_store with a proper servers configuration' do + expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_store: kind_of(::Redis::Store))) - it 'initialized with Multistore as ENV var defaults to true' do - expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_store: kind_of(::Redis::Store))) - - load_session_store - end - end - - context 'when the GITLAB_USE_REDIS_SESSIONS_STORE env is disabled' do - before do - stub_env('GITLAB_USE_REDIS_SESSIONS_STORE', false) - end - - it 'initialized as a redis_store with a proper servers configuration' do - expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_store: kind_of(Redis::Store))) - - load_session_store - end - end - - context 'when the GITLAB_USE_REDIS_SESSIONS_STORE env is enabled' do - before do - stub_env('GITLAB_USE_REDIS_SESSIONS_STORE', true) - end - - it 'initialized as a redis_store with a proper servers configuration' do - expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_store: kind_of(::Redis::Store))) - - load_session_store - end + load_session_store end end end diff --git a/spec/lib/api/entities/ci/pipeline_spec.rb b/spec/lib/api/entities/ci/pipeline_spec.rb index 6a658cc3e18..2b8e59b68c6 100644 --- a/spec/lib/api/entities/ci/pipeline_spec.rb +++ b/spec/lib/api/entities/ci/pipeline_spec.rb @@ -3,14 +3,31 @@ require 'spec_helper' RSpec.describe API::Entities::Ci::Pipeline do - let_it_be(:pipeline) { create(:ci_empty_pipeline) } + let_it_be(:user) { create(:user) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, user: user) } let_it_be(:job) { create(:ci_build, name: "rspec", coverage: 30.212, pipeline: pipeline) } let(:entity) { described_class.new(pipeline) } subject { entity.as_json } - it 'returns the coverage as a string' do + exposed_fields = %i[before_sha tag yaml_errors created_at updated_at started_at finished_at committed_at duration queued_duration] + + exposed_fields.each do |field| + it "exposes pipeline #{field}" do + expect(subject[field]).to eq(pipeline.public_send(field)) + end + end + + it 'exposes pipeline user basic information' do + expect(subject[:user].keys).to include(:avatar_url, :web_url) + end + + it 'exposes pipeline detailed status' do + expect(subject[:detailed_status].keys).to include(:icon, :favicon) + end + + it 'exposes pipeline coverage as a string' do expect(subject[:coverage]).to eq '30.21' end end diff --git a/spec/lib/api/entities/merge_request_basic_spec.rb b/spec/lib/api/entities/merge_request_basic_spec.rb index b9d6ab7a652..40f259b86e2 100644 --- a/spec/lib/api/entities/merge_request_basic_spec.rb +++ b/spec/lib/api/entities/merge_request_basic_spec.rb @@ -21,7 +21,8 @@ RSpec.describe ::API::Entities::MergeRequestBasic do it 'includes basic fields' do is_expected.to include( draft: merge_request.draft?, - work_in_progress: merge_request.draft? + work_in_progress: merge_request.draft?, + merge_user: nil ) end diff --git a/spec/lib/api/helpers/rate_limiter_spec.rb b/spec/lib/api/helpers/rate_limiter_spec.rb new file mode 100644 index 00000000000..2fed1cf3604 --- /dev/null +++ b/spec/lib/api/helpers/rate_limiter_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Helpers::RateLimiter do + let(:key) { :some_key } + let(:scope) { [:some, :scope] } + let(:request) { instance_double('Rack::Request') } + let(:user) { build_stubbed(:user) } + + let(:api_class) do + Class.new do + include API::Helpers::RateLimiter + + attr_reader :request, :current_user + + def initialize(request, current_user) + @request = request + @current_user = current_user + end + + def render_api_error!(**args) + end + end + end + + subject { api_class.new(request, user) } + + before do + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?) + allow(::Gitlab::ApplicationRateLimiter).to receive(:log_request) + end + + describe '#check_rate_limit!' do + it 'calls ApplicationRateLimiter#throttled? with the right arguments' do + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(key, scope: scope).and_return(false) + expect(subject).not_to receive(:render_api_error!) + + subject.check_rate_limit!(key, scope: scope) + end + + it 'renders api error and logs request if throttled' do + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(key, scope: scope).and_return(true) + expect(::Gitlab::ApplicationRateLimiter).to receive(:log_request).with(request, "#{key}_request_limit".to_sym, user) + expect(subject).to receive(:render_api_error!).with({ error: _('This endpoint has been requested too many times. Try again later.') }, 429) + + subject.check_rate_limit!(key, scope: scope) + end + + context 'when the bypass header is set' do + before do + allow(Gitlab::Throttle).to receive(:bypass_header).and_return('SOME_HEADER') + end + + it 'skips rate limit if set to "1"' do + allow(request).to receive(:get_header).with(Gitlab::Throttle.bypass_header).and_return('1') + + expect(::Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) + expect(subject).not_to receive(:render_api_error!) + + subject.check_rate_limit!(key, scope: scope) + end + + it 'does not skip rate limit if set to something else than "1"' do + allow(request).to receive(:get_header).with(Gitlab::Throttle.bypass_header).and_return('0') + + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?) + + subject.check_rate_limit!(key, scope: scope) + end + end + end +end diff --git a/spec/lib/backup/artifacts_spec.rb b/spec/lib/backup/artifacts_spec.rb index 5a965030b01..102d787a5e1 100644 --- a/spec/lib/backup/artifacts_spec.rb +++ b/spec/lib/backup/artifacts_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Backup::Artifacts do Dir.mktmpdir do |tmpdir| allow(JobArtifactUploader).to receive(:root) { "#{tmpdir}" } - expect(backup.app_files_dir).to eq("#{tmpdir}") + expect(backup.app_files_dir).to eq("#{File.realpath(tmpdir)}") end end end diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb index 92de191da2d..6bff0919293 100644 --- a/spec/lib/backup/files_spec.rb +++ b/spec/lib/backup/files_spec.rb @@ -134,7 +134,7 @@ RSpec.describe Backup::Files do expect do subject.dump - end.to raise_error(/Backup operation failed:/) + end.to raise_error(/Failed to create compressed file/) end describe 'with STRATEGY=copy' do @@ -170,7 +170,7 @@ RSpec.describe Backup::Files do expect do subject.dump end.to output(/rsync failed/).to_stdout - .and raise_error(/Backup failed/) + .and raise_error(/Failed to create compressed file/) end end end diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb index 2ccde517533..cd0d984fbdb 100644 --- a/spec/lib/backup/gitaly_backup_spec.rb +++ b/spec/lib/backup/gitaly_backup_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe Backup::GitalyBackup do - let(:parallel) { nil } - let(:parallel_storage) { nil } + let(:max_parallelism) { nil } + let(:storage_parallelism) { nil } let(:progress) do Tempfile.new('progress').tap do |progress| @@ -23,7 +23,7 @@ RSpec.describe Backup::GitalyBackup do progress.close end - subject { described_class.new(progress, parallel: parallel, parallel_storage: parallel_storage) } + subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism) } context 'unknown' do it 'fails to start unknown' do @@ -48,7 +48,7 @@ RSpec.describe Backup::GitalyBackup do subject.enqueue(project, Gitlab::GlRepository::DESIGN) subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait + subject.finish! expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle')) expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle')) @@ -58,24 +58,24 @@ RSpec.describe Backup::GitalyBackup do end context 'parallel option set' do - let(:parallel) { 3 } + let(:max_parallelism) { 3 } it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel', '3').and_call_original subject.start(:create) - subject.wait + subject.finish! end end context 'parallel_storage option set' do - let(:parallel_storage) { 3 } + let(:storage_parallelism) { 3 } it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel-storage', '3').and_call_original subject.start(:create) - subject.wait + subject.finish! end end @@ -83,7 +83,7 @@ RSpec.describe Backup::GitalyBackup do expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false')) subject.start(:create) - expect { subject.wait }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') + expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') end end @@ -115,7 +115,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything).and_call_original subject.start(:create) - subject.wait + subject.finish! end end end @@ -145,7 +145,7 @@ RSpec.describe Backup::GitalyBackup do subject.enqueue(project, Gitlab::GlRepository::DESIGN) subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait + subject.finish! collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) } @@ -157,24 +157,24 @@ RSpec.describe Backup::GitalyBackup do end context 'parallel option set' do - let(:parallel) { 3 } + let(:max_parallelism) { 3 } it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel', '3').and_call_original subject.start(:restore) - subject.wait + subject.finish! end end context 'parallel_storage option set' do - let(:parallel_storage) { 3 } + let(:storage_parallelism) { 3 } it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel-storage', '3').and_call_original subject.start(:restore) - subject.wait + subject.finish! end end @@ -182,7 +182,7 @@ RSpec.describe Backup::GitalyBackup do expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false')) subject.start(:restore) - expect { subject.wait }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') + expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') end end end diff --git a/spec/lib/backup/gitaly_rpc_backup_spec.rb b/spec/lib/backup/gitaly_rpc_backup_spec.rb index fb442f4a86f..14f9d27ca6e 100644 --- a/spec/lib/backup/gitaly_rpc_backup_spec.rb +++ b/spec/lib/backup/gitaly_rpc_backup_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Backup::GitalyRpcBackup do subject.enqueue(project, Gitlab::GlRepository::DESIGN) subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait + subject.finish! expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle')) expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle')) @@ -52,7 +52,7 @@ RSpec.describe Backup::GitalyRpcBackup do it 'logs an appropriate message', :aggregate_failures do subject.start(:create) subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.wait + subject.finish! expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})") expect(progress).to have_received(:puts).with("Error Fail in tests") @@ -96,7 +96,7 @@ RSpec.describe Backup::GitalyRpcBackup do subject.enqueue(project, Gitlab::GlRepository::DESIGN) subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait + subject.finish! collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) } @@ -129,7 +129,7 @@ RSpec.describe Backup::GitalyRpcBackup do subject.enqueue(project, Gitlab::GlRepository::DESIGN) subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.wait + subject.finish! end context 'failure' do @@ -143,7 +143,7 @@ RSpec.describe Backup::GitalyRpcBackup do it 'logs an appropriate message', :aggregate_failures do subject.start(:restore) subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.wait + subject.finish! expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})") expect(progress).to have_received(:puts).with("Error Fail in tests") diff --git a/spec/lib/backup/lfs_spec.rb b/spec/lib/backup/lfs_spec.rb new file mode 100644 index 00000000000..fdc1c0c885d --- /dev/null +++ b/spec/lib/backup/lfs_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Backup::Lfs do + let(:progress) { StringIO.new } + + subject(:backup) { described_class.new(progress) } + + describe '#dump' do + before do + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with('/var/lfs-objects').and_return('/var/lfs-objects') + allow(File).to receive(:realpath).with('/var/lfs-objects/..').and_return('/var') + allow(Settings.lfs).to receive(:storage_path).and_return('/var/lfs-objects') + end + + it 'uses the correct lfs dir in tar command', :aggregate_failures do + expect(backup.app_files_dir).to eq('/var/lfs-objects') + expect(backup).to receive(:tar).and_return('blabla-tar') + expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found -C /var/lfs-objects -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) + expect(backup).to receive(:pipeline_succeeded?).and_return(true) + + backup.dump + end + end +end diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb index 32eea82cfdf..31cc3012eb1 100644 --- a/spec/lib/backup/manager_spec.rb +++ b/spec/lib/backup/manager_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Backup::Manager do end describe '#pack' do - let(:expected_backup_contents) { %w(repositories db uploads.tar.gz builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz backup_information.yml) } + let(:expected_backup_contents) { %w(repositories db uploads.tar.gz builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz terraform_state.tar.gz packages.tar.gz backup_information.yml) } let(:tar_file) { '1546300800_2019_01_01_12.3_gitlab_backup.tar' } let(:tar_system_options) { { out: [tar_file, 'w', Gitlab.config.backup.archive_permissions] } } let(:tar_cmdline) { ['tar', '-cf', '-', *expected_backup_contents, tar_system_options] } @@ -57,7 +57,7 @@ RSpec.describe Backup::Manager do end context 'when skipped is set in backup_information.yml' do - let(:expected_backup_contents) { %w{db uploads.tar.gz builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz backup_information.yml} } + let(:expected_backup_contents) { %w{db uploads.tar.gz builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz terraform_state.tar.gz packages.tar.gz backup_information.yml} } let(:backup_information) do { backup_created_at: Time.zone.parse('2019-01-01'), @@ -74,7 +74,7 @@ RSpec.describe Backup::Manager do end context 'when a directory does not exist' do - let(:expected_backup_contents) { %w{db uploads.tar.gz builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz backup_information.yml} } + let(:expected_backup_contents) { %w{db uploads.tar.gz builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz terraform_state.tar.gz packages.tar.gz backup_information.yml} } before do expect(Dir).to receive(:exist?).with(File.join(Gitlab.config.backup.path, 'repositories')).and_return(false) diff --git a/spec/lib/backup/object_backup_spec.rb b/spec/lib/backup/object_backup_spec.rb new file mode 100644 index 00000000000..6192b5c3482 --- /dev/null +++ b/spec/lib/backup/object_backup_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'backup object' do |setting| + let(:progress) { StringIO.new } + let(:backup_path) { "/var/#{setting}" } + + subject(:backup) { described_class.new(progress) } + + describe '#dump' do + before do + allow(File).to receive(:realpath).and_call_original + allow(File).to receive(:realpath).with(backup_path).and_return(backup_path) + allow(File).to receive(:realpath).with("#{backup_path}/..").and_return('/var') + allow(Settings.send(setting)).to receive(:storage_path).and_return(backup_path) + end + + it 'uses the correct storage dir in tar command and excludes tmp', :aggregate_failures do + expect(backup.app_files_dir).to eq(backup_path) + expect(backup).to receive(:tar).and_return('blabla-tar') + expect(backup).to receive(:run_pipeline!).with([%W(blabla-tar --exclude=lost+found --exclude=./tmp -C #{backup_path} -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) + expect(backup).to receive(:pipeline_succeeded?).and_return(true) + + backup.dump + end + end +end + +RSpec.describe Backup::Packages do + it_behaves_like 'backup object', 'packages' +end + +RSpec.describe Backup::TerraformState do + it_behaves_like 'backup object', 'terraform_state' +end diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index 85818038c9d..f3830da344b 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -25,7 +25,7 @@ RSpec.describe Backup::Repositories do expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN) expect(strategy).to have_received(:enqueue).with(project_snippet, Gitlab::GlRepository::SNIPPET) expect(strategy).to have_received(:enqueue).with(personal_snippet, Gitlab::GlRepository::SNIPPET) - expect(strategy).to have_received(:wait) + expect(strategy).to have_received(:finish!) end end @@ -49,7 +49,7 @@ RSpec.describe Backup::Repositories do projects.each do |project| expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) end - expect(strategy).to receive(:wait) + expect(strategy).to receive(:finish!) subject.dump(max_concurrency: 1, max_storage_concurrency: 1) end @@ -91,7 +91,7 @@ RSpec.describe Backup::Repositories do projects.each do |project| expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) end - expect(strategy).to receive(:wait) + expect(strategy).to receive(:finish!) subject.dump(max_concurrency: 2, max_storage_concurrency: 2) end @@ -114,7 +114,7 @@ RSpec.describe Backup::Repositories do projects.each do |project| expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) end - expect(strategy).to receive(:wait) + expect(strategy).to receive(:finish!) subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) end @@ -128,7 +128,7 @@ RSpec.describe Backup::Repositories do projects.each do |project| expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) end - expect(strategy).to receive(:wait) + expect(strategy).to receive(:finish!) subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency) end @@ -184,7 +184,7 @@ RSpec.describe Backup::Repositories do expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN) expect(strategy).to have_received(:enqueue).with(project_snippet, Gitlab::GlRepository::SNIPPET) expect(strategy).to have_received(:enqueue).with(personal_snippet, Gitlab::GlRepository::SNIPPET) - expect(strategy).to have_received(:wait) + expect(strategy).to have_received(:finish!) end context 'restoring object pools' do diff --git a/spec/lib/backup/repository_backup_error_spec.rb b/spec/lib/backup/repository_backup_error_spec.rb deleted file mode 100644 index 44c75c1cf77..00000000000 --- a/spec/lib/backup/repository_backup_error_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::RepositoryBackupError do - let_it_be(:snippet) { create(:snippet, content: 'foo', file_name: 'foo') } - let_it_be(:project) { create(:project, :repository) } - let_it_be(:wiki) { ProjectWiki.new(project, nil ) } - - let(:backup_repos_path) { '/tmp/backup/repositories' } - - shared_examples 'includes backup path' do - it { is_expected.to respond_to :container } - it { is_expected.to respond_to :backup_repos_path } - - it 'expects exception message to include repo backup path location' do - expect(subject.message).to include("#{subject.backup_repos_path}") - end - - it 'expects exception message to include container being back-up' do - expect(subject.message).to include("#{subject.container.disk_path}") - end - end - - context 'with snippet repository' do - subject { described_class.new(snippet, backup_repos_path) } - - it_behaves_like 'includes backup path' - end - - context 'with project repository' do - subject { described_class.new(project, backup_repos_path) } - - it_behaves_like 'includes backup path' - end - - context 'with wiki repository' do - subject { described_class.new(wiki, backup_repos_path) } - - it_behaves_like 'includes backup path' - end -end diff --git a/spec/lib/backup/uploads_spec.rb b/spec/lib/backup/uploads_spec.rb index a82cb764f4d..c173916fe91 100644 --- a/spec/lib/backup/uploads_spec.rb +++ b/spec/lib/backup/uploads_spec.rb @@ -14,13 +14,14 @@ RSpec.describe Backup::Uploads do allow(Gitlab.config.uploads).to receive(:storage_path) { tmpdir } - expect(backup.app_files_dir).to eq("#{tmpdir}/uploads") + expect(backup.app_files_dir).to eq("#{File.realpath(tmpdir)}/uploads") end end end describe '#dump' do before do + allow(File).to receive(:realpath).and_call_original allow(File).to receive(:realpath).with('/var/uploads').and_return('/var/uploads') allow(File).to receive(:realpath).with('/var/uploads/..').and_return('/var') allow(Gitlab.config.uploads).to receive(:storage_path) { '/var' } diff --git a/spec/lib/banzai/filter/footnote_filter_spec.rb b/spec/lib/banzai/filter/footnote_filter_spec.rb index d41f5e8633d..5ac7d3af733 100644 --- a/spec/lib/banzai/filter/footnote_filter_spec.rb +++ b/spec/lib/banzai/filter/footnote_filter_spec.rb @@ -56,52 +56,6 @@ RSpec.describe Banzai::Filter::FootnoteFilter do it 'properly adds the necessary ids and classes' do expect(doc.to_html).to eq filtered_footnote.strip end - - context 'using ruby-based HTML renderer' do - # first[^1] and second[^second] - # [^1]: one - # [^second]: two - let(:footnote) do - <<~EOF - <p>first<sup><a href="#fn1" id="fnref1">1</a></sup> and second<sup><a href="#fn2" id="fnref2">2</a></sup></p> - <p>same reference<sup><a href="#fn1" id="fnref1">1</a></sup></p> - <ol> - <li id="fn1"> - <p>one <a href="#fnref1">↩</a></p> - </li> - <li id="fn2"> - <p>two <a href="#fnref2">↩</a></p> - </li> - </ol> - EOF - end - - let(:filtered_footnote) do - <<~EOF - <p>first<sup class="footnote-ref"><a href="#fn1-#{identifier}" id="fnref1-#{identifier}">1</a></sup> and second<sup class="footnote-ref"><a href="#fn2-#{identifier}" id="fnref2-#{identifier}">2</a></sup></p> - <p>same reference<sup class="footnote-ref"><a href="#fn1-#{identifier}" id="fnref1-#{identifier}">1</a></sup></p> - <section class="footnotes"><ol> - <li id="fn1-#{identifier}"> - <p>one <a href="#fnref1-#{identifier}" class="footnote-backref">↩</a></p> - </li> - <li id="fn2-#{identifier}"> - <p>two <a href="#fnref2-#{identifier}" class="footnote-backref">↩</a></p> - </li> - </ol></section> - EOF - end - - let(:doc) { filter(footnote) } - let(:identifier) { link_node[:id].delete_prefix('fnref1-') } - - before do - stub_feature_flags(use_cmark_renderer: false) - end - - it 'properly adds the necessary ids and classes' do - expect(doc.to_html).to eq filtered_footnote - end - end end context 'when detecting footnotes' do diff --git a/spec/lib/banzai/filter/markdown_filter_spec.rb b/spec/lib/banzai/filter/markdown_filter_spec.rb index 1c9b894e885..e3c8d121587 100644 --- a/spec/lib/banzai/filter/markdown_filter_spec.rb +++ b/spec/lib/banzai/filter/markdown_filter_spec.rb @@ -5,125 +5,90 @@ require 'spec_helper' RSpec.describe Banzai::Filter::MarkdownFilter do include FilterSpecHelper - shared_examples_for 'renders correct markdown' do - describe 'markdown engine from context' do - it 'defaults to CommonMark' do - expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| - expect(instance).to receive(:render).and_return('test') - end - - filter('test') + describe 'markdown engine from context' do + it 'defaults to CommonMark' do + expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| + expect(instance).to receive(:render).and_return('test') end - it 'uses CommonMark' do - expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| - expect(instance).to receive(:render).and_return('test') - end + filter('test') + end - filter('test', { markdown_engine: :common_mark }) + it 'uses CommonMark' do + expect_next_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) do |instance| + expect(instance).to receive(:render).and_return('test') end + + filter('test', { markdown_engine: :common_mark }) end + end - describe 'code block' do - context 'using CommonMark' do - before do - stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) - end - - it 'adds language to lang attribute when specified' do - result = filter("```html\nsome code\n```", no_sourcepos: true) - - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - expect(result).to start_with('<pre lang="html"><code>') - else - expect(result).to start_with('<pre><code lang="html">') - end - end - - it 'does not add language to lang attribute when not specified' do - result = filter("```\nsome code\n```", no_sourcepos: true) - - expect(result).to start_with('<pre><code>') - end - - it 'works with utf8 chars in language' do - result = filter("```æ—¥\nsome code\n```", no_sourcepos: true) - - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - expect(result).to start_with('<pre lang="æ—¥"><code>') - else - expect(result).to start_with('<pre><code lang="æ—¥">') - end - end - - it 'works with additional language parameters' do - result = filter("```ruby:red gem foo\nsome code\n```", no_sourcepos: true) - - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - expect(result).to start_with('<pre lang="ruby:red" data-meta="gem foo"><code>') - else - expect(result).to start_with('<pre><code lang="ruby:red gem foo">') - end - end + describe 'code block' do + context 'using CommonMark' do + before do + stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) end - end - describe 'source line position' do - context 'using CommonMark' do - before do - stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) - end + it 'adds language to lang attribute when specified' do + result = filter("```html\nsome code\n```", no_sourcepos: true) - it 'defaults to add data-sourcepos' do - result = filter('test') + expect(result).to start_with('<pre lang="html"><code>') + end - expect(result).to eq '<p data-sourcepos="1:1-1:4">test</p>' - end + it 'does not add language to lang attribute when not specified' do + result = filter("```\nsome code\n```", no_sourcepos: true) - it 'disables data-sourcepos' do - result = filter('test', no_sourcepos: true) + expect(result).to start_with('<pre><code>') + end + + it 'works with utf8 chars in language' do + result = filter("```æ—¥\nsome code\n```", no_sourcepos: true) - expect(result).to eq '<p>test</p>' - end + expect(result).to start_with('<pre lang="æ—¥"><code>') + end + + it 'works with additional language parameters' do + result = filter("```ruby:red gem foo\nsome code\n```", no_sourcepos: true) + + expect(result).to start_with('<pre lang="ruby:red" data-meta="gem foo"><code>') end end + end - describe 'footnotes in tables' do - it 'processes footnotes in table cells' do - text = <<-MD.strip_heredoc - | Column1 | - | --------- | - | foot [^1] | + describe 'source line position' do + context 'using CommonMark' do + before do + stub_const('Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE', :common_mark) + end - [^1]: a footnote - MD + it 'defaults to add data-sourcepos' do + result = filter('test') - result = filter(text, no_sourcepos: true) + expect(result).to eq '<p data-sourcepos="1:1-1:4">test</p>' + end - expect(result).to include('<td>foot <sup') + it 'disables data-sourcepos' do + result = filter('test', no_sourcepos: true) - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - expect(result).to include('<section class="footnotes" data-footnotes>') - else - expect(result).to include('<section class="footnotes">') - end + expect(result).to eq '<p>test</p>' end end end - context 'using ruby-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: false) - end + describe 'footnotes in tables' do + it 'processes footnotes in table cells' do + text = <<-MD.strip_heredoc + | Column1 | + | --------- | + | foot [^1] | - it_behaves_like 'renders correct markdown' - end + [^1]: a footnote + MD - context 'using c-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: true) - end + result = filter(text, no_sourcepos: true) - it_behaves_like 'renders correct markdown' + expect(result).to include('<td>foot <sup') + expect(result).to include('<section class="footnotes" data-footnotes>') + end end end diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb index e1e02c09fbe..2d1a01116e0 100644 --- a/spec/lib/banzai/filter/plantuml_filter_spec.rb +++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb @@ -5,67 +5,33 @@ require 'spec_helper' RSpec.describe Banzai::Filter::PlantumlFilter do include FilterSpecHelper - shared_examples_for 'renders correct markdown' do - it 'replaces plantuml pre tag with img tag' do - stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") + it 'replaces plantuml pre tag with img tag' do + stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080") - input = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' - else - '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' - end + input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' + output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>' + doc = filter(input) - output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>' - doc = filter(input) - - expect(doc.to_s).to eq output - end - - it 'does not replace plantuml pre tag with img tag if disabled' do - stub_application_setting(plantuml_enabled: false) - - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' - output = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' - else - input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' - output = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' - end - - doc = filter(input) - - expect(doc.to_s).to eq output - end - - it 'does not replace plantuml pre tag with img tag if url is invalid' do - stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") + expect(doc.to_s).to eq output + end - input = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' - else - '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' - end + it 'does not replace plantuml pre tag with img tag if disabled' do + stub_application_setting(plantuml_enabled: false) - output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' - doc = filter(input) + input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' + output = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' + doc = filter(input) - expect(doc.to_s).to eq output - end + expect(doc.to_s).to eq output end - context 'using ruby-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: false) - end - - it_behaves_like 'renders correct markdown' - end + it 'does not replace plantuml pre tag with img tag if url is invalid' do + stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") - context 'using c-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: true) - end + input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>' + output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' + doc = filter(input) - it_behaves_like 'renders correct markdown' + expect(doc.to_s).to eq output end end diff --git a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb index 14c1542b724..b3523a25116 100644 --- a/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/issue_reference_filter_spec.rb @@ -122,6 +122,7 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do expect(link).to have_attribute('data-reference-format') expect(link.attr('data-reference-format')).to eq('+') + expect(link.attr('href')).to eq(issue_url) end it 'includes a data-reference-format attribute for URL references' do @@ -130,6 +131,7 @@ RSpec.describe Banzai::Filter::References::IssueReferenceFilter do expect(link).to have_attribute('data-reference-format') expect(link.attr('data-reference-format')).to eq('+') + expect(link.attr('href')).to eq(issue_url) end it 'supports an :only_path context' do diff --git a/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb b/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb index 3c488820853..e5809ac6949 100644 --- a/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/merge_request_reference_filter_spec.rb @@ -51,6 +51,7 @@ RSpec.describe Banzai::Filter::References::MergeRequestReferenceFilter do context 'internal reference' do let(:reference) { merge.to_reference } + let(:merge_request_url) { urls.project_merge_request_url(project, merge) } it 'links to a valid reference' do doc = reference_filter("See #{reference}") @@ -115,14 +116,16 @@ RSpec.describe Banzai::Filter::References::MergeRequestReferenceFilter do expect(link).to have_attribute('data-reference-format') expect(link.attr('data-reference-format')).to eq('+') + expect(link.attr('href')).to eq(merge_request_url) end it 'includes a data-reference-format attribute for URL references' do - doc = reference_filter("Merge #{urls.project_merge_request_url(project, merge)}+") + doc = reference_filter("Merge #{merge_request_url}+") link = doc.css('a').first expect(link).to have_attribute('data-reference-format') expect(link.attr('data-reference-format')).to eq('+') + expect(link.attr('href')).to eq(merge_request_url) end it 'supports an :only_path context' do diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index 24e787bddd5..039ca36af6e 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -177,53 +177,6 @@ RSpec.describe Banzai::Filter::SanitizationFilter do expect(act.to_html).to eq exp end end - - context 'using ruby-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: false) - end - - it 'allows correct footnote id property on links' do - exp = %q(<a href="#fn1" id="fnref1">foo/bar.md</a>) - act = filter(exp) - - expect(act.to_html).to eq exp - end - - it 'allows correct footnote id property on li element' do - exp = %q(<ol><li id="fn1">footnote</li></ol>) - act = filter(exp) - - expect(act.to_html).to eq exp - end - - it 'removes invalid id for footnote links' do - exp = %q(<a href="#fn1">link</a>) - - %w[fnrefx test xfnref1].each do |id| - act = filter(%(<a href="#fn1" id="#{id}">link</a>)) - - expect(act.to_html).to eq exp - end - end - - it 'removes invalid id for footnote li' do - exp = %q(<ol><li>footnote</li></ol>) - - %w[fnx test xfn1].each do |id| - act = filter(%(<ol><li id="#{id}">footnote</li></ol>)) - - expect(act.to_html).to eq exp - end - end - - it 'allows footnotes numbered higher than 9' do - exp = %q(<a href="#fn15" id="fnref15">link</a><ol><li id="fn15">footnote</li></ol>) - act = filter(exp) - - expect(act.to_html).to eq exp - end - end end end end diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb index ef46fd62486..aee4bd93207 100644 --- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb +++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb @@ -19,202 +19,150 @@ RSpec.describe Banzai::Filter::SyntaxHighlightFilter do end end - shared_examples_for 'renders correct markdown' do - context "when no language is specified" do - it "highlights as plaintext" do - result = filter('<pre><code>def fun end</code></pre>') + context "when no language is specified" do + it "highlights as plaintext" do + result = filter('<pre><code>def fun end</code></pre>') - expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre><copy-code></copy-code></div>') - end - - include_examples "XSS prevention", "" + expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre><copy-code></copy-code></div>') end - context "when contains mermaid diagrams" do - it "ignores mermaid blocks" do - result = filter('<pre data-mermaid-style="display"><code>mermaid code</code></pre>') + include_examples "XSS prevention", "" + end - expect(result.to_html).to eq('<pre data-mermaid-style="display"><code>mermaid code</code></pre>') - end + context "when contains mermaid diagrams" do + it "ignores mermaid blocks" do + result = filter('<pre data-mermaid-style="display"><code>mermaid code</code></pre>') + + expect(result.to_html).to eq('<pre data-mermaid-style="display"><code>mermaid code</code></pre>') end + end - context "when a valid language is specified" do - it "highlights as that language" do - result = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - filter('<pre lang="ruby"><code>def fun end</code></pre>') - else - filter('<pre><code lang="ruby">def fun end</code></pre>') - end + context "when <pre> contains multiple <code> tags" do + it "ignores the block" do + result = filter('<pre><code>one</code> and <code>two</code></pre>') - expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre><copy-code></copy-code></div>') - end + expect(result.to_html).to eq('<pre><code>one</code> and <code>two</code></pre>') + end + end - include_examples "XSS prevention", "ruby" + context "when a valid language is specified" do + it "highlights as that language" do + result = filter('<pre lang="ruby"><code>def fun end</code></pre>') + + expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre><copy-code></copy-code></div>') end - context "when an invalid language is specified" do - it "highlights as plaintext" do - result = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - filter('<pre lang="gnuplot"><code>This is a test</code></pre>') - else - filter('<pre><code lang="gnuplot">This is a test</code></pre>') - end + include_examples "XSS prevention", "ruby" + end - expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>') - end + context "when an invalid language is specified" do + it "highlights as plaintext" do + result = filter('<pre lang="gnuplot"><code>This is a test</code></pre>') - include_examples "XSS prevention", "gnuplot" + expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>') end - context "languages that should be passed through" do - let(:delimiter) { described_class::LANG_PARAMS_DELIMITER } - let(:data_attr) { described_class::LANG_PARAMS_ATTR } + include_examples "XSS prevention", "gnuplot" + end - %w(math mermaid plantuml suggestion).each do |lang| - context "when #{lang} is specified" do - it "highlights as plaintext but with the correct language attribute and class" do - result = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - filter(%{<pre lang="#{lang}"><code>This is a test</code></pre>}) - else - filter(%{<pre><code lang="#{lang}">This is a test</code></pre>}) - end + context "languages that should be passed through" do + let(:delimiter) { described_class::LANG_PARAMS_DELIMITER } + let(:data_attr) { described_class::LANG_PARAMS_ATTR } - expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>}) - end + %w(math mermaid plantuml suggestion).each do |lang| + context "when #{lang} is specified" do + it "highlights as plaintext but with the correct language attribute and class" do + result = filter(%{<pre lang="#{lang}"><code>This is a test</code></pre>}) - include_examples "XSS prevention", lang + expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>}) end - context "when #{lang} has extra params" do - let(:lang_params) { 'foo-bar-kux' } - - let(:xss_lang) do - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>" - else - "#{lang}#{described_class::LANG_PARAMS_DELIMITER}<script>alert(1)</script>" - end - end - - it "includes data-lang-params tag with extra information" do - result = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - filter(%{<pre lang="#{lang}" data-meta="#{lang_params}"><code>This is a test</code></pre>}) - else - filter(%{<pre><code lang="#{lang}#{delimiter}#{lang_params}">This is a test</code></pre>}) - end - - expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>}) - end - - include_examples "XSS prevention", lang - - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - include_examples "XSS prevention", - "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>" - else - include_examples "XSS prevention", - "#{lang}#{described_class::LANG_PARAMS_DELIMITER}<script>alert(1)</script>" - end - - include_examples "XSS prevention", - "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>" - end + include_examples "XSS prevention", lang end - context 'when multiple param delimiters are used' do - let(:lang) { 'suggestion' } - let(:lang_params) { '-1+10' } + context "when #{lang} has extra params" do + let(:lang_params) { 'foo-bar-kux' } + let(:xss_lang) { "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>" } - let(:expected_result) do - %{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params} more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>} - end - - context 'when delimiter is space' do - it 'delimits on the first appearance' do - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - result = filter(%{<pre lang="#{lang}" data-meta="#{lang_params} more-things"><code>This is a test</code></pre>}) + it "includes data-lang-params tag with extra information" do + result = filter(%{<pre lang="#{lang}" data-meta="#{lang_params}"><code>This is a test</code></pre>}) - expect(result.to_html.delete("\n")).to eq(expected_result) - else - result = filter(%{<pre><code lang="#{lang}#{delimiter}#{lang_params}#{delimiter}more-things">This is a test</code></pre>}) - - expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}#{delimiter}more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>}) - end - end + expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params}" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>}) end - context 'when delimiter is colon' do - it 'delimits on the first appearance' do - result = filter(%{<pre lang="#{lang}#{delimiter}#{lang_params} more-things"><code>This is a test</code></pre>}) + include_examples "XSS prevention", lang - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - expect(result.to_html.delete("\n")).to eq(expected_result) - else - expect(result.to_html.delete("\n")).to eq(%{<div class="gl-relative markdown-code-block js-markdown-code"><pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">This is a test</span></code></pre><copy-code></copy-code></div>}) - end - end - end + include_examples "XSS prevention", + "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>" + + include_examples "XSS prevention", + "#{lang} data-meta=\"foo-bar-kux\"<script>alert(1)</script>" end end - context "when sourcepos metadata is available" do - it "includes it in the highlighted code block" do - result = filter('<pre data-sourcepos="1:1-3:3"><code lang="plaintext">This is a test</code></pre>') + context 'when multiple param delimiters are used' do + let(:lang) { 'suggestion' } + let(:lang_params) { '-1+10' } - expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>') + let(:expected_result) do + %{<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight language-#{lang}" lang="#{lang}" #{data_attr}="#{lang_params} more-things" v-pre="true"><code><span id="LC1" class="line" lang="#{lang}">This is a test</span></code></pre><copy-code></copy-code></div>} end - end - context "when Rouge lexing fails" do - before do - allow_next_instance_of(Rouge::Lexers::Ruby) do |instance| - allow(instance).to receive(:stream_tokens).and_raise(StandardError) + context 'when delimiter is space' do + it 'delimits on the first appearance' do + result = filter(%{<pre lang="#{lang}" data-meta="#{lang_params} more-things"><code>This is a test</code></pre>}) + + expect(result.to_html.delete("\n")).to eq(expected_result) end end - it "highlights as plaintext" do - result = if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - filter('<pre lang="ruby"><code>This is a test</code></pre>') - else - filter('<pre><code lang="ruby">This is a test</code></pre>') - end + context 'when delimiter is colon' do + it 'delimits on the first appearance' do + result = filter(%{<pre lang="#{lang}#{delimiter}#{lang_params} more-things"><code>This is a test</code></pre>}) - expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight" lang="" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre><copy-code></copy-code></div>') + expect(result.to_html.delete("\n")).to eq(expected_result) + end end - - include_examples "XSS prevention", "ruby" end + end - context "when Rouge lexing fails after a retry" do - before do - allow_next_instance_of(Rouge::Lexers::PlainText) do |instance| - allow(instance).to receive(:stream_tokens).and_raise(StandardError) - end - end + context "when sourcepos metadata is available" do + it "includes it in the highlighted code block" do + result = filter('<pre data-sourcepos="1:1-3:3"><code lang="plaintext">This is a test</code></pre>') - it "does not add highlighting classes" do - result = filter('<pre><code>This is a test</code></pre>') + expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre data-sourcepos="1:1-3:3" class="code highlight js-syntax-highlight language-plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre><copy-code></copy-code></div>') + end + end - expect(result.to_html).to eq('<pre><code>This is a test</code></pre>') + context "when Rouge lexing fails" do + before do + allow_next_instance_of(Rouge::Lexers::Ruby) do |instance| + allow(instance).to receive(:stream_tokens).and_raise(StandardError) end + end + + it "highlights as plaintext" do + result = filter('<pre lang="ruby"><code>This is a test</code></pre>') - include_examples "XSS prevention", "ruby" + expect(result.to_html.delete("\n")).to eq('<div class="gl-relative markdown-code-block js-markdown-code"><pre class="code highlight js-syntax-highlight" lang="" v-pre="true"><code><span id="LC1" class="line" lang="">This is a test</span></code></pre><copy-code></copy-code></div>') end + + include_examples "XSS prevention", "ruby" end - context 'using ruby-based HTML renderer' do + context "when Rouge lexing fails after a retry" do before do - stub_feature_flags(use_cmark_renderer: false) + allow_next_instance_of(Rouge::Lexers::PlainText) do |instance| + allow(instance).to receive(:stream_tokens).and_raise(StandardError) + end end - it_behaves_like 'renders correct markdown' - end + it "does not add highlighting classes" do + result = filter('<pre><code>This is a test</code></pre>') - context 'using c-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: true) + expect(result.to_html).to eq('<pre><code>This is a test</code></pre>') end - it_behaves_like 'renders correct markdown' + include_examples "XSS prevention", "ruby" end end diff --git a/spec/lib/banzai/pipeline/full_pipeline_spec.rb b/spec/lib/banzai/pipeline/full_pipeline_spec.rb index 620b7d97a5b..376edfb99fc 100644 --- a/spec/lib/banzai/pipeline/full_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/full_pipeline_spec.rb @@ -65,47 +65,6 @@ RSpec.describe Banzai::Pipeline::FullPipeline do expect(html.lines.map(&:strip).join("\n")).to eq filtered_footnote.strip end - - context 'using ruby-based HTML renderer' do - let(:html) { described_class.to_html(footnote_markdown, project: project) } - let(:identifier) { html[/.*fnref1-(\d+).*/, 1] } - let(:footnote_markdown) do - <<~EOF - first[^1] and second[^second] and twenty[^twenty] - [^1]: one - [^second]: two - [^twenty]: twenty - EOF - end - - let(:filtered_footnote) do - <<~EOF - <p dir="auto">first<sup class="footnote-ref"><a href="#fn1-#{identifier}" id="fnref1-#{identifier}">1</a></sup> and second<sup class="footnote-ref"><a href="#fn2-#{identifier}" id="fnref2-#{identifier}">2</a></sup> and twenty<sup class="footnote-ref"><a href="#fn3-#{identifier}" id="fnref3-#{identifier}">3</a></sup></p> - - <section class="footnotes"><ol> - <li id="fn1-#{identifier}"> - <p>one <a href="#fnref1-#{identifier}" class="footnote-backref"><gl-emoji title="leftwards arrow with hook" data-name="leftwards_arrow_with_hook" data-unicode-version="1.1">↩</gl-emoji></a></p> - </li> - <li id="fn2-#{identifier}"> - <p>two <a href="#fnref2-#{identifier}" class="footnote-backref"><gl-emoji title="leftwards arrow with hook" data-name="leftwards_arrow_with_hook" data-unicode-version="1.1">↩</gl-emoji></a></p> - </li> - <li id="fn3-#{identifier}"> - <p>twenty <a href="#fnref3-#{identifier}" class="footnote-backref"><gl-emoji title="leftwards arrow with hook" data-name="leftwards_arrow_with_hook" data-unicode-version="1.1">↩</gl-emoji></a></p> - </li> - </ol></section> - EOF - end - - before do - stub_feature_flags(use_cmark_renderer: false) - end - - it 'properly adds the necessary ids and classes' do - stub_commonmark_sourcepos_disabled - - expect(html.lines.map(&:strip).join("\n")).to eq filtered_footnote - end - end end describe 'links are detected as malicious' do diff --git a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb index c8cd9d4fcac..80392fe264f 100644 --- a/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/plain_markdown_pipeline_spec.rb @@ -5,117 +5,93 @@ require 'spec_helper' RSpec.describe Banzai::Pipeline::PlainMarkdownPipeline do using RSpec::Parameterized::TableSyntax - shared_examples_for 'renders correct markdown' do - describe 'CommonMark tests', :aggregate_failures do - it 'converts all reference punctuation to literals' do - reference_chars = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS - markdown = reference_chars.split('').map {|char| char.prepend("\\") }.join - punctuation = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.split('') - punctuation = punctuation.delete_if {|char| char == '&' } - punctuation << '&' - - result = described_class.call(markdown, project: project) - output = result[:output].to_html - - punctuation.each { |char| expect(output).to include("<span>#{char}</span>") } - expect(result[:escaped_literals]).to be_truthy - end + describe 'backslash escapes', :aggregate_failures do + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, project: project) } - it 'ensure we handle all the GitLab reference characters', :eager_load do - reference_chars = ObjectSpace.each_object(Class).map do |klass| - next unless klass.included_modules.include?(Referable) - next unless klass.respond_to?(:reference_prefix) - next unless klass.reference_prefix.length == 1 + it 'converts all reference punctuation to literals' do + reference_chars = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS + markdown = reference_chars.split('').map {|char| char.prepend("\\") }.join + punctuation = Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.split('') + punctuation = punctuation.delete_if {|char| char == '&' } + punctuation << '&' - klass.reference_prefix - end.compact + result = described_class.call(markdown, project: project) + output = result[:output].to_html - reference_chars.all? do |char| - Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.include?(char) - end - end + punctuation.each { |char| expect(output).to include("<span>#{char}</span>") } + expect(result[:escaped_literals]).to be_truthy + end - it 'does not convert non-reference punctuation to spans' do - markdown = %q(\"\'\*\+\,\-\.\/\:\;\<\=\>\?\[\]\_\`\{\|\}) + %q[\(\)\\\\] + it 'ensure we handle all the GitLab reference characters', :eager_load do + reference_chars = ObjectSpace.each_object(Class).map do |klass| + next unless klass.included_modules.include?(Referable) + next unless klass.respond_to?(:reference_prefix) + next unless klass.reference_prefix.length == 1 - result = described_class.call(markdown, project: project) - output = result[:output].to_html + klass.reference_prefix + end.compact - expect(output).not_to include('<span>') - expect(result[:escaped_literals]).to be_falsey + reference_chars.all? do |char| + Banzai::Filter::MarkdownPreEscapeFilter::REFERENCE_CHARACTERS.include?(char) end + end - it 'does not convert other characters to literals' do - markdown = %q(\→\A\a\ \3\φ\«) - expected = '\→\A\a\ \3\φ\«' - - result = correct_html_included(markdown, expected) - expect(result[:escaped_literals]).to be_falsey - end + it 'does not convert non-reference punctuation to spans' do + markdown = %q(\"\'\*\+\,\-\.\/\:\;\<\=\>\?\[\]\_\`\{\|\}) + %q[\(\)\\\\] - describe 'backslash escapes do not work in code blocks, code spans, autolinks, or raw HTML' do - where(:markdown, :expected) do - %q(`` \@\! ``) | %q(<code>\@\!</code>) - %q( \@\!) | %Q(<code>\\@\\!\n</code>) - %Q(~~~\n\\@\\!\n~~~) | %Q(<code>\\@\\!\n</code>) - %q(<http://example.com?find=\@>) | %q(<a href="http://example.com?find=%5C@">http://example.com?find=\@</a>) - %q[<a href="/bar\@)">] | %q[<a href="/bar%5C@)">] - end - - with_them do - it { correct_html_included(markdown, expected) } - end - end + result = described_class.call(markdown, project: project) + output = result[:output].to_html - describe 'work in all other contexts, including URLs and link titles, link references, and info strings in fenced code blocks' do - let(:markdown) { %Q(``` foo\\@bar\nfoo\n```) } - - it 'renders correct html' do - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - correct_html_included(markdown, %Q(<pre data-sourcepos="1:1-3:3" lang="foo@bar"><code>foo\n</code></pre>)) - else - correct_html_included(markdown, %Q(<code lang="foo@bar">foo\n</code>)) - end - end - - where(:markdown, :expected) do - %q![foo](/bar\@ "\@title")! | %q(<a href="/bar@" title="@title">foo</a>) - %Q![foo]\n\n[foo]: /bar\\@ "\\@title"! | %q(<a href="/bar@" title="@title">foo</a>) - end - - with_them do - it { correct_html_included(markdown, expected) } - end - end + expect(output).not_to include('<span>') + expect(result[:escaped_literals]).to be_falsey end - end - - describe 'backslash escapes' do - let_it_be(:project) { create(:project, :public) } - let_it_be(:issue) { create(:issue, project: project) } - - def correct_html_included(markdown, expected) - result = described_class.call(markdown, {}) - expect(result[:output].to_html).to include(expected) + it 'does not convert other characters to literals' do + markdown = %q(\→\A\a\ \3\φ\«) + expected = '\→\A\a\ \3\φ\«' - result + result = correct_html_included(markdown, expected) + expect(result[:escaped_literals]).to be_falsey end - context 'using ruby-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: false) + describe 'backslash escapes do not work in code blocks, code spans, autolinks, or raw HTML' do + where(:markdown, :expected) do + %q(`` \@\! ``) | %q(<code>\@\!</code>) + %q( \@\!) | %Q(<code>\\@\\!\n</code>) + %Q(~~~\n\\@\\!\n~~~) | %Q(<code>\\@\\!\n</code>) + %q(<http://example.com?find=\@>) | %q(<a href="http://example.com?find=%5C@">http://example.com?find=\@</a>) + %q[<a href="/bar\@)">] | %q[<a href="/bar%5C@)">] end - it_behaves_like 'renders correct markdown' + with_them do + it { correct_html_included(markdown, expected) } + end end - context 'using c-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: true) + describe 'work in all other contexts, including URLs and link titles, link references, and info strings in fenced code blocks' do + let(:markdown) { %Q(``` foo\\@bar\nfoo\n```) } + + it 'renders correct html' do + correct_html_included(markdown, %Q(<pre data-sourcepos="1:1-3:3" lang="foo@bar"><code>foo\n</code></pre>)) + end + + where(:markdown, :expected) do + %q![foo](/bar\@ "\@title")! | %q(<a href="/bar@" title="@title">foo</a>) + %Q![foo]\n\n[foo]: /bar\\@ "\\@title"! | %q(<a href="/bar@" title="@title">foo</a>) end - it_behaves_like 'renders correct markdown' + with_them do + it { correct_html_included(markdown, expected) } + end end end + + def correct_html_included(markdown, expected) + result = described_class.call(markdown, {}) + + expect(result[:output].to_html).to include(expected) + + result + end end diff --git a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb index 04c35c8b082..3fbda7f3239 100644 --- a/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/merge_request_parser_spec.rb @@ -23,14 +23,6 @@ RSpec.describe Banzai::ReferenceParser::MergeRequestParser do end it_behaves_like "referenced feature visibility", "merge_requests" - - context 'when optimize_merge_request_parser feature flag is off' do - before do - stub_feature_flags(optimize_merge_request_parser: false) - end - - it_behaves_like "referenced feature visibility", "merge_requests" - end end end diff --git a/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb b/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb index bd306233de8..d6e19a5fc85 100644 --- a/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb +++ b/spec/lib/bulk_imports/common/extractors/ndjson_extractor_spec.rb @@ -16,7 +16,7 @@ RSpec.describe BulkImports::Common::Extractors::NdjsonExtractor do before do allow(FileUtils).to receive(:remove_entry).with(any_args).and_call_original - subject.instance_variable_set(:@tmp_dir, tmpdir) + subject.instance_variable_set(:@tmpdir, tmpdir) end after(:all) do @@ -43,11 +43,11 @@ RSpec.describe BulkImports::Common::Extractors::NdjsonExtractor do end end - describe '#remove_tmp_dir' do + describe '#remove_tmpdir' do it 'removes tmp dir' do expect(FileUtils).to receive(:remove_entry).with(tmpdir).once - subject.remove_tmp_dir + subject.remove_tmpdir end end end diff --git a/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb b/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb index 3b5ea131d0d..9d43bb3ebfb 100644 --- a/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb +++ b/spec/lib/bulk_imports/common/pipelines/uploads_pipeline_spec.rb @@ -3,10 +3,10 @@ require 'spec_helper' RSpec.describe BulkImports::Common::Pipelines::UploadsPipeline do - let_it_be(:tmpdir) { Dir.mktmpdir } let_it_be(:project) { create(:project) } let_it_be(:group) { create(:group) } + let(:tmpdir) { Dir.mktmpdir } let(:uploads_dir_path) { File.join(tmpdir, '72a497a02fe3ee09edae2ed06d390038') } let(:upload_file_path) { File.join(uploads_dir_path, 'upload.txt')} let(:tracker) { create(:bulk_import_tracker, entity: entity) } @@ -80,10 +80,10 @@ RSpec.describe BulkImports::Common::Pipelines::UploadsPipeline do .with( configuration: context.configuration, relative_url: "/#{entity.pluralized_name}/test/export_relations/download?relation=uploads", - dir: tmpdir, + tmpdir: tmpdir, filename: 'uploads.tar.gz') .and_return(download_service) - expect(BulkImports::FileDecompressionService).to receive(:new).with(dir: tmpdir, filename: 'uploads.tar.gz').and_return(decompression_service) + expect(BulkImports::FileDecompressionService).to receive(:new).with(tmpdir: tmpdir, filename: 'uploads.tar.gz').and_return(decompression_service) expect(BulkImports::ArchiveExtractionService).to receive(:new).with(tmpdir: tmpdir, filename: 'uploads.tar').and_return(extraction_service) expect(download_service).to receive(:execute) @@ -123,6 +123,31 @@ RSpec.describe BulkImports::Common::Pipelines::UploadsPipeline do end end end + + describe '#after_run' do + before do + allow(Dir).to receive(:mktmpdir).with('bulk_imports').and_return(tmpdir) + end + + it 'removes tmp dir' do + allow(FileUtils).to receive(:remove_entry).and_call_original + expect(FileUtils).to receive(:remove_entry).with(tmpdir).and_call_original + + pipeline.after_run(nil) + + expect(Dir.exist?(tmpdir)).to eq(false) + end + + context 'when dir does not exist' do + it 'does not attempt to remove tmpdir' do + FileUtils.remove_entry(tmpdir) + + expect(FileUtils).not_to receive(:remove_entry).with(tmpdir) + + pipeline.after_run(nil) + end + end + end end context 'when importing to group' do diff --git a/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb b/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb index 11c475318bb..df7ff5b8062 100644 --- a/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb +++ b/spec/lib/bulk_imports/projects/pipelines/project_attributes_pipeline_spec.rb @@ -56,7 +56,7 @@ RSpec.describe BulkImports::Projects::Pipelines::ProjectAttributesPipeline do subject(:pipeline) { described_class.new(context) } before do - allow(Dir).to receive(:mktmpdir).and_return(tmpdir) + allow(Dir).to receive(:mktmpdir).with('bulk_imports').and_return(tmpdir) end after do @@ -95,13 +95,13 @@ RSpec.describe BulkImports::Projects::Pipelines::ProjectAttributesPipeline do .with( configuration: context.configuration, relative_url: "/#{entity.pluralized_name}/#{entity.source_full_path}/export_relations/download?relation=self", - dir: tmpdir, + tmpdir: tmpdir, filename: 'self.json.gz') .and_return(file_download_service) expect(BulkImports::FileDecompressionService) .to receive(:new) - .with(dir: tmpdir, filename: 'self.json.gz') + .with(tmpdir: tmpdir, filename: 'self.json.gz') .and_return(file_decompression_service) expect(file_download_service).to receive(:execute) @@ -156,4 +156,25 @@ RSpec.describe BulkImports::Projects::Pipelines::ProjectAttributesPipeline do pipeline.json_attributes end end + + describe '#after_run' do + it 'removes tmp dir' do + allow(FileUtils).to receive(:remove_entry).and_call_original + expect(FileUtils).to receive(:remove_entry).with(tmpdir).and_call_original + + pipeline.after_run(nil) + + expect(Dir.exist?(tmpdir)).to eq(false) + end + + context 'when dir does not exist' do + it 'does not attempt to remove tmpdir' do + FileUtils.remove_entry(tmpdir) + + expect(FileUtils).not_to receive(:remove_entry).with(tmpdir) + + pipeline.after_run(nil) + end + end + end end diff --git a/spec/lib/error_tracking/collector/payload_validator_spec.rb b/spec/lib/error_tracking/collector/payload_validator_spec.rb index ab5ec448dff..94708f63bf4 100644 --- a/spec/lib/error_tracking/collector/payload_validator_spec.rb +++ b/spec/lib/error_tracking/collector/payload_validator_spec.rb @@ -18,37 +18,25 @@ RSpec.describe ErrorTracking::Collector::PayloadValidator do end end - context 'ruby payload' do - let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/parsed_event.json')) } - - it_behaves_like 'valid payload' - end - - context 'python payload' do - let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/python_event.json')) } - - it_behaves_like 'valid payload' - end - - context 'python payload in repl' do - let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/python_event_repl.json')) } - - it_behaves_like 'valid payload' - end + context 'with event fixtures' do + where(:event_fixture) do + Dir.glob(Rails.root.join('spec/fixtures/error_tracking/*event*.json')) + end - context 'browser payload' do - let(:payload) { Gitlab::Json.parse(fixture_file('error_tracking/browser_event.json')) } + with_them do + let(:payload) { Gitlab::Json.parse(fixture_file(event_fixture)) } - it_behaves_like 'valid payload' + it_behaves_like 'valid payload' + end end - context 'empty payload' do + context 'when empty' do let(:payload) { '' } it_behaves_like 'invalid payload' end - context 'invalid payload' do + context 'when invalid' do let(:payload) { { 'foo' => 'bar' } } it_behaves_like 'invalid payload' diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb index 82580d5d700..8c546390201 100644 --- a/spec/lib/feature_spec.rb +++ b/spec/lib/feature_spec.rb @@ -3,12 +3,40 @@ require 'spec_helper' RSpec.describe Feature, stub_feature_flags: false do + include StubVersion + before do # reset Flipper AR-engine Feature.reset skip_feature_flags_yaml_validation end + describe '.feature_flags_available?' do + it 'returns false on connection error' do + expect(ActiveRecord::Base.connection).to receive(:active?).and_raise(PG::ConnectionBad) # rubocop:disable Database/MultipleDatabases + + expect(described_class.feature_flags_available?).to eq(false) + end + + it 'returns false when connection is not active' do + expect(ActiveRecord::Base.connection).to receive(:active?).and_return(false) # rubocop:disable Database/MultipleDatabases + + expect(described_class.feature_flags_available?).to eq(false) + end + + it 'returns false when the flipper table does not exist' do + expect(Feature::FlipperFeature).to receive(:table_exists?).and_return(false) + + expect(described_class.feature_flags_available?).to eq(false) + end + + it 'returns false on NoDatabaseError' do + expect(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError) + + expect(described_class.feature_flags_available?).to eq(false) + end + end + describe '.get' do let(:feature) { double(:feature) } let(:key) { 'my_feature' } @@ -585,6 +613,10 @@ RSpec.describe Feature, stub_feature_flags: false do context 'when flag is new and not feature_flag_state_logs' do let(:milestone) { "14.6" } + before do + stub_version('14.5.123', 'deadbeef') + end + it { is_expected.to be_truthy } end diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 7200ff3c4db..44bbbe49cd3 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -11,13 +11,27 @@ module Gitlab allow_any_instance_of(ApplicationSetting).to receive(:current).and_return(::ApplicationSetting.create_from_defaults) end - shared_examples_for 'renders correct asciidoc' do - context "without project" do - let(:input) { '<b>ascii</b>' } - let(:context) { {} } - let(:html) { 'H<sub>2</sub>O' } + context "without project" do + let(:input) { '<b>ascii</b>' } + let(:context) { {} } + let(:html) { 'H<sub>2</sub>O' } + + it "converts the input using Asciidoctor and default options" do + expected_asciidoc_opts = { + safe: :secure, + backend: :gitlab_html5, + attributes: described_class::DEFAULT_ADOC_ATTRS.merge({ "kroki-server-url" => nil }), + extensions: be_a(Proc) + } + + expect(Asciidoctor).to receive(:convert) + .with(input, expected_asciidoc_opts).and_return(html) + + expect(render(input, context)).to eq(html) + end - it "converts the input using Asciidoctor and default options" do + context "with asciidoc_opts" do + it "merges the options with default ones" do expected_asciidoc_opts = { safe: :secure, backend: :gitlab_html5, @@ -28,845 +42,808 @@ module Gitlab expect(Asciidoctor).to receive(:convert) .with(input, expected_asciidoc_opts).and_return(html) - expect(render(input, context)).to eq(html) + render(input, context) end + end - context "with asciidoc_opts" do - it "merges the options with default ones" do - expected_asciidoc_opts = { - safe: :secure, - backend: :gitlab_html5, - attributes: described_class::DEFAULT_ADOC_ATTRS.merge({ "kroki-server-url" => nil }), - extensions: be_a(Proc) - } + context "with requested path" do + input = <<~ADOC + Document name: {docname}. + ADOC + + it "ignores {docname} when not available" do + expect(render(input, {})).to include(input.strip) + end + + [ + ['/', '', 'root'], + ['README', 'README', 'just a filename'], + ['doc/api/', '', 'a directory'], + ['doc/api/README.adoc', 'README', 'a complete path'] + ].each do |path, basename, desc| + it "sets {docname} for #{desc}" do + expect(render(input, { requested_path: path })).to include(": #{basename}.") + end + end + end - expect(Asciidoctor).to receive(:convert) - .with(input, expected_asciidoc_opts).and_return(html) + context "XSS" do + items = { + 'link with extra attribute' => { + input: 'link:mylink"onmouseover="alert(1)[Click Here]', + output: "<div>\n<p><a href=\"mylink\">Click Here</a></p>\n</div>" + }, + 'link with unsafe scheme' => { + input: 'link:data://danger[Click Here]', + output: "<div>\n<p><a>Click Here</a></p>\n</div>" + }, + 'image with onerror' => { + input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', + output: "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt='Alt text\" onerror=\"alert(7)' class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" + } + } - render(input, context) + items.each do |name, data| + it "does not convert dangerous #{name} into HTML" do + expect(render(data[:input], context)).to include(data[:output]) end end - context "with requested path" do + # `stub_feature_flags method` runs AFTER declaration of `items` above. + # So the spec in its current implementation won't pass. + # Move this test back to the items hash when removing `use_cmark_renderer` feature flag. + it "does not convert dangerous fenced code with inline script into HTML" do + input = '```mypre"><script>alert(3)</script>' + output = "<div>\n<div>\n<div class=\"gl-relative markdown-code-block js-markdown-code\">\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code></code></pre>\n<copy-code></copy-code>\n</div>\n</div>\n</div>" + + expect(render(input, context)).to include(output) + end + + it 'does not allow locked attributes to be overridden' do input = <<~ADOC - Document name: {docname}. + {counter:max-include-depth:1234} + <|-- {max-include-depth} ADOC - it "ignores {docname} when not available" do - expect(render(input, {})).to include(input.strip) - end + expect(render(input, {})).not_to include('1234') + end + end - [ - ['/', '', 'root'], - ['README', 'README', 'just a filename'], - ['doc/api/', '', 'a directory'], - ['doc/api/README.adoc', 'README', 'a complete path'] - ].each do |path, basename, desc| - it "sets {docname} for #{desc}" do - expect(render(input, { requested_path: path })).to include(": #{basename}.") - end - end + context "images" do + it "does lazy load and link image" do + input = 'image:https://localhost.com/image.png[]' + output = "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" + expect(render(input, context)).to include(output) end - context "XSS" do - items = { - 'link with extra attribute' => { - input: 'link:mylink"onmouseover="alert(1)[Click Here]', - output: "<div>\n<p><a href=\"mylink\">Click Here</a></p>\n</div>" - }, - 'link with unsafe scheme' => { - input: 'link:data://danger[Click Here]', - output: "<div>\n<p><a>Click Here</a></p>\n</div>" - }, - 'image with onerror' => { - input: 'image:https://localhost.com/image.png[Alt text" onerror="alert(7)]', - output: "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt='Alt text\" onerror=\"alert(7)' class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" - } - } + it "does not automatically link image if link is explicitly defined" do + input = 'image:https://localhost.com/image.png[link=https://gitlab.com]' + output = "<div>\n<p><span><a href=\"https://gitlab.com\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" + expect(render(input, context)).to include(output) + end + end - items.each do |name, data| - it "does not convert dangerous #{name} into HTML" do - expect(render(data[:input], context)).to include(data[:output]) - end - end + context 'with admonition' do + it 'preserves classes' do + input = <<~ADOC + NOTE: An admonition paragraph, like this note, grabs the reader’s attention. + ADOC - # `stub_feature_flags method` runs AFTER declaration of `items` above. - # So the spec in its current implementation won't pass. - # Move this test back to the items hash when removing `use_cmark_renderer` feature flag. - it "does not convert dangerous fenced code with inline script into HTML" do - input = '```mypre"><script>alert(3)</script>' - output = - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - "<div>\n<div>\n<div class=\"gl-relative markdown-code-block js-markdown-code\">\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code></code></pre>\n<copy-code></copy-code>\n</div>\n</div>\n</div>" - else - "<div>\n<div>\n<div class=\"gl-relative markdown-code-block js-markdown-code\">\n<pre class=\"code highlight js-syntax-highlight language-plaintext\" lang=\"plaintext\" v-pre=\"true\"><code><span id=\"LC1\" class=\"line\" lang=\"plaintext\">\"></span></code></pre>\n<copy-code></copy-code>\n</div>\n</div>\n</div>" - end + output = <<~HTML + <div class="admonitionblock"> + <table> + <tr> + <td class="icon"> + <i class="fa icon-note" title="Note"></i> + </td> + <td> + An admonition paragraph, like this note, grabs the reader’s attention. + </td> + </tr> + </table> + </div> + HTML + + expect(render(input, context)).to include(output.strip) + end + end - expect(render(input, context)).to include(output) - end + context 'with passthrough' do + it 'removes non heading ids' do + input = <<~ADOC + ++++ + <h2 id="foo">Title</h2> + ++++ + ADOC - it 'does not allow locked attributes to be overridden' do - input = <<~ADOC - {counter:max-include-depth:1234} - <|-- {max-include-depth} - ADOC + output = <<~HTML + <h2>Title</h2> + HTML - expect(render(input, {})).not_to include('1234') - end + expect(render(input, context)).to include(output.strip) end - context "images" do - it "does lazy load and link image" do - input = 'image:https://localhost.com/image.png[]' - output = "<div>\n<p><span><a class=\"no-attachment-icon\" href=\"https://localhost.com/image.png\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" - expect(render(input, context)).to include(output) - end + it 'removes non footnote def ids' do + input = <<~ADOC + ++++ + <div id="def">Footnote definition</div> + ++++ + ADOC - it "does not automatically link image if link is explicitly defined" do - input = 'image:https://localhost.com/image.png[link=https://gitlab.com]' - output = "<div>\n<p><span><a href=\"https://gitlab.com\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"image\" class=\"lazy\" data-src=\"https://localhost.com/image.png\"></a></span></p>\n</div>" - expect(render(input, context)).to include(output) - end + output = <<~HTML + <div>Footnote definition</div> + HTML + + expect(render(input, context)).to include(output.strip) end - context 'with admonition' do - it 'preserves classes' do - input = <<~ADOC - NOTE: An admonition paragraph, like this note, grabs the reader’s attention. - ADOC + it 'removes non footnote ref ids' do + input = <<~ADOC + ++++ + <a id="ref">Footnote reference</a> + ++++ + ADOC - output = <<~HTML - <div class="admonitionblock"> - <table> - <tr> - <td class="icon"> - <i class="fa icon-note" title="Note"></i> - </td> - <td> - An admonition paragraph, like this note, grabs the reader’s attention. - </td> - </tr> - </table> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + output = <<~HTML + <a>Footnote reference</a> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with passthrough' do - it 'removes non heading ids' do - input = <<~ADOC - ++++ - <h2 id="foo">Title</h2> - ++++ - ADOC + context 'with footnotes' do + it 'preserves ids and links' do + input = <<~ADOC + This paragraph has a footnote.footnote:[This is the text of the footnote.] + ADOC - output = <<~HTML - <h2>Title</h2> - HTML + output = <<~HTML + <div> + <p>This paragraph has a footnote.<sup>[<a id="_footnoteref_1" href="#_footnotedef_1" title="View footnote.">1</a>]</sup></p> + </div> + <div> + <hr> + <div id="_footnotedef_1"> + <a href="#_footnoteref_1">1</a>. This is the text of the footnote. + </div> + </div> + HTML + + expect(render(input, context)).to include(output.strip) + end + end - expect(render(input, context)).to include(output.strip) - end + context 'with section anchors' do + it 'preserves ids and links' do + input = <<~ADOC + = Title - it 'removes non footnote def ids' do - input = <<~ADOC - ++++ - <div id="def">Footnote definition</div> - ++++ - ADOC + == First section - output = <<~HTML - <div>Footnote definition</div> - HTML + This is the first section. - expect(render(input, context)).to include(output.strip) - end + == Second section - it 'removes non footnote ref ids' do - input = <<~ADOC - ++++ - <a id="ref">Footnote reference</a> - ++++ - ADOC + This is the second section. - output = <<~HTML - <a>Footnote reference</a> - HTML + == Thunder âš¡ ! - expect(render(input, context)).to include(output.strip) - end + This is the third section. + ADOC + + output = <<~HTML + <h1>Title</h1> + <div> + <h2 id="user-content-first-section"> + <a class="anchor" href="#user-content-first-section"></a>First section</h2> + <div> + <div> + <p>This is the first section.</p> + </div> + </div> + </div> + <div> + <h2 id="user-content-second-section"> + <a class="anchor" href="#user-content-second-section"></a>Second section</h2> + <div> + <div> + <p>This is the second section.</p> + </div> + </div> + </div> + <div> + <h2 id="user-content-thunder"> + <a class="anchor" href="#user-content-thunder"></a>Thunder âš¡ !</h2> + <div> + <div> + <p>This is the third section.</p> + </div> + </div> + </div> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with footnotes' do - it 'preserves ids and links' do - input = <<~ADOC - This paragraph has a footnote.footnote:[This is the text of the footnote.] - ADOC + context 'with xrefs' do + it 'preserves ids' do + input = <<~ADOC + Learn how to xref:cross-references[use cross references]. - output = <<~HTML - <div> - <p>This paragraph has a footnote.<sup>[<a id="_footnoteref_1" href="#_footnotedef_1" title="View footnote.">1</a>]</sup></p> - </div> - <div> - <hr> - <div id="_footnotedef_1"> - <a href="#_footnoteref_1">1</a>. This is the text of the footnote. - </div> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + [[cross-references]]A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref). + ADOC + + output = <<~HTML + <div> + <p>Learn how to <a href="#cross-references">use cross references</a>.</p> + </div> + <div> + <p><a id="user-content-cross-references"></a>A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref).</p> + </div> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with section anchors' do - it 'preserves ids and links' do - input = <<~ADOC - = Title - - == First section - - This is the first section. - - == Second section - - This is the second section. - - == Thunder âš¡ ! - - This is the third section. - ADOC + context 'with checklist' do + it 'preserves classes' do + input = <<~ADOC + * [x] checked + * [ ] not checked + ADOC - output = <<~HTML - <h1>Title</h1> - <div> - <h2 id="user-content-first-section"> - <a class="anchor" href="#user-content-first-section"></a>First section</h2> - <div> - <div> - <p>This is the first section.</p> - </div> - </div> - </div> - <div> - <h2 id="user-content-second-section"> - <a class="anchor" href="#user-content-second-section"></a>Second section</h2> - <div> - <div> - <p>This is the second section.</p> - </div> - </div> - </div> - <div> - <h2 id="user-content-thunder"> - <a class="anchor" href="#user-content-thunder"></a>Thunder âš¡ !</h2> - <div> - <div> - <p>This is the third section.</p> - </div> - </div> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + output = <<~HTML + <div> + <ul class="checklist"> + <li> + <p><i class="fa fa-check-square-o"></i> checked</p> + </li> + <li> + <p><i class="fa fa-square-o"></i> not checked</p> + </li> + </ul> + </div> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with xrefs' do - it 'preserves ids' do - input = <<~ADOC - Learn how to xref:cross-references[use cross references]. - - [[cross-references]]A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref). - ADOC + context 'with marks' do + it 'preserves classes' do + input = <<~ADOC + Werewolves are allergic to #cassia cinnamon#. - output = <<~HTML - <div> - <p>Learn how to <a href="#cross-references">use cross references</a>.</p> - </div> - <div> - <p><a id="user-content-cross-references"></a>A link to another location within an AsciiDoc document or between AsciiDoc documents is called a cross reference (also referred to as an xref).</p> - </div> - HTML + Did the werewolves read the [.small]#small print#? - expect(render(input, context)).to include(output.strip) - end + Where did all the [.underline.small]#cores# run off to? + + We need [.line-through]#ten# make that twenty VMs. + + [.big]##O##nce upon an infinite loop. + ADOC + + output = <<~HTML + <div> + <p>Werewolves are allergic to <mark>cassia cinnamon</mark>.</p> + </div> + <div> + <p>Did the werewolves read the <span class="small">small print</span>?</p> + </div> + <div> + <p>Where did all the <span class="underline small">cores</span> run off to?</p> + </div> + <div> + <p>We need <span class="line-through">ten</span> make that twenty VMs.</p> + </div> + <div> + <p><span class="big">O</span>nce upon an infinite loop.</p> + </div> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with checklist' do - it 'preserves classes' do - input = <<~ADOC - * [x] checked - * [ ] not checked - ADOC + context 'with fenced block' do + it 'highlights syntax' do + input = <<~ADOC + ```js + console.log('hello world') + ``` + ADOC - output = <<~HTML - <div> - <ul class="checklist"> - <li> - <p><i class="fa fa-check-square-o"></i> checked</p> - </li> - <li> - <p><i class="fa fa-square-o"></i> not checked</p> - </li> - </ul> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + output = <<~HTML + <div> + <div> + <div class="gl-relative markdown-code-block js-markdown-code"> + <pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span></span></code></pre> + <copy-code></copy-code> + </div> + </div> + </div> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with marks' do - it 'preserves classes' do - input = <<~ADOC - Werewolves are allergic to #cassia cinnamon#. - - Did the werewolves read the [.small]#small print#? - - Where did all the [.underline.small]#cores# run off to? - - We need [.line-through]#ten# make that twenty VMs. - - [.big]##O##nce upon an infinite loop. - ADOC + context 'with listing block' do + it 'highlights syntax' do + input = <<~ADOC + [source,c++] + .class.cpp + ---- + #include <stdio.h> - output = <<~HTML - <div> - <p>Werewolves are allergic to <mark>cassia cinnamon</mark>.</p> - </div> - <div> - <p>Did the werewolves read the <span class="small">small print</span>?</p> - </div> - <div> - <p>Where did all the <span class="underline small">cores</span> run off to?</p> - </div> - <div> - <p>We need <span class="line-through">ten</span> make that twenty VMs.</p> - </div> - <div> - <p><span class="big">O</span>nce upon an infinite loop.</p> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + for (int i = 0; i < 5; i++) { + std::cout<<"*"<<std::endl; + } + ---- + ADOC + + output = <<~HTML + <div> + <div>class.cpp</div> + <div> + <div class="gl-relative markdown-code-block js-markdown-code"> + <pre class="code highlight js-syntax-highlight language-cpp" lang="cpp" v-pre="true"><code><span id="LC1" class="line" lang="cpp"><span class="cp">#include <stdio.h></span></span> + <span id="LC2" class="line" lang="cpp"></span> + <span id="LC3" class="line" lang="cpp"><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o"><</span> <span class="mi">5</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span></span> + <span id="LC4" class="line" lang="cpp"> <span class="n">std</span><span class="o">::</span><span class="n">cout</span><span class="o"><<</span><span class="s">"*"</span><span class="o"><<</span><span class="n">std</span><span class="o">::</span><span class="n">endl</span><span class="p">;</span></span> + <span id="LC5" class="line" lang="cpp"><span class="p">}</span></span></code></pre> + <copy-code></copy-code> + </div> + </div> + </div> + HTML + + expect(render(input, context)).to include(output.strip) end + end - context 'with fenced block' do - it 'highlights syntax' do - input = <<~ADOC - ```js - console.log('hello world') - ``` - ADOC + context 'with stem block' do + it 'does not apply syntax highlighting' do + input = <<~ADOC + [stem] + ++++ + \sqrt{4} = 2 + ++++ + ADOC - output = <<~HTML - <div> - <div> - <div class="gl-relative markdown-code-block js-markdown-code"> - <pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">hello world</span><span class="dl">'</span><span class="p">)</span></span></code></pre> - <copy-code></copy-code> - </div> - </div> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + output = "<div>\n<div>\n\\$ qrt{4} = 2\\$\n</div>\n</div>" + + expect(render(input, context)).to include(output) end + end - context 'with listing block' do - it 'highlights syntax' do - input = <<~ADOC - [source,c++] - .class.cpp - ---- - #include <stdio.h> - - for (int i = 0; i < 5; i++) { - std::cout<<"*"<<std::endl; - } - ---- - ADOC + context 'external links' do + it 'adds the `rel` attribute to the link' do + output = render('link:https://google.com[Google]', context) - output = <<~HTML - <div> - <div>class.cpp</div> - <div> - <div class="gl-relative markdown-code-block js-markdown-code"> - <pre class="code highlight js-syntax-highlight language-cpp" lang="cpp" v-pre="true"><code><span id="LC1" class="line" lang="cpp"><span class="cp">#include <stdio.h></span></span> - <span id="LC2" class="line" lang="cpp"></span> - <span id="LC3" class="line" lang="cpp"><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o"><</span> <span class="mi">5</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span></span> - <span id="LC4" class="line" lang="cpp"> <span class="n">std</span><span class="o">::</span><span class="n">cout</span><span class="o"><<</span><span class="s">"*"</span><span class="o"><<</span><span class="n">std</span><span class="o">::</span><span class="n">endl</span><span class="p">;</span></span> - <span id="LC5" class="line" lang="cpp"><span class="p">}</span></span></code></pre> - <copy-code></copy-code> - </div> - </div> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + expect(output).to include('rel="nofollow noreferrer noopener"') end + end - context 'with stem block' do - it 'does not apply syntax highlighting' do - input = <<~ADOC - [stem] - ++++ - \sqrt{4} = 2 - ++++ - ADOC + context 'LaTex code' do + it 'adds class js-render-math to the output' do + input = <<~MD + :stem: latexmath - output = "<div>\n<div>\n\\$ qrt{4} = 2\\$\n</div>\n</div>" + [stem] + ++++ + \sqrt{4} = 2 + ++++ - expect(render(input, context)).to include(output) - end + another part + + [latexmath] + ++++ + \beta_x \gamma + ++++ + + stem:[2+2] is 4 + MD + + expect(render(input, context)).to include('<pre data-math-style="display" class="code math js-render-math"><code>eta_x gamma</code></pre>') + expect(render(input, context)).to include('<p><code data-math-style="inline" class="code math js-render-math">2+2</code> is 4</p>') end + end - context 'external links' do - it 'adds the `rel` attribute to the link' do - output = render('link:https://google.com[Google]', context) + context 'outfilesuffix' do + it 'defaults to adoc' do + output = render("Inter-document reference <<README.adoc#>>", context) - expect(output).to include('rel="nofollow noreferrer noopener"') - end + expect(output).to include("a href=\"README.adoc\"") end + end - context 'LaTex code' do - it 'adds class js-render-math to the output' do - input = <<~MD - :stem: latexmath - - [stem] - ++++ - \sqrt{4} = 2 - ++++ - - another part - - [latexmath] - ++++ - \beta_x \gamma - ++++ - - stem:[2+2] is 4 - MD - - expect(render(input, context)).to include('<pre data-math-style="display" class="code math js-render-math"><code>eta_x gamma</code></pre>') - expect(render(input, context)).to include('<p><code data-math-style="inline" class="code math js-render-math">2+2</code> is 4</p>') - end + context 'with mermaid diagrams' do + it 'adds class js-render-mermaid to the output' do + input = <<~MD + [mermaid] + .... + graph LR + A[Square Rect] -- Link text --> B((Circle)) + A --> C(Round Rect) + B --> D{Rhombus} + C --> D + .... + MD + + output = <<~HTML + <pre data-mermaid-style="display" class="js-render-mermaid">graph LR + A[Square Rect] -- Link text --> B((Circle)) + A --> C(Round Rect) + B --> D{Rhombus} + C --> D</pre> + HTML + + expect(render(input, context)).to include(output.strip) end - context 'outfilesuffix' do - it 'defaults to adoc' do - output = render("Inter-document reference <<README.adoc#>>", context) + it 'applies subs in diagram block' do + input = <<~MD + :class-name: AveryLongClass - expect(output).to include("a href=\"README.adoc\"") - end - end + [mermaid,subs=+attributes] + .... + classDiagram + Class01 <|-- {class-name} : Cool + .... + MD - context 'with mermaid diagrams' do - it 'adds class js-render-mermaid to the output' do - input = <<~MD - [mermaid] - .... - graph LR - A[Square Rect] -- Link text --> B((Circle)) - A --> C(Round Rect) - B --> D{Rhombus} - C --> D - .... - MD - - output = <<~HTML - <pre data-mermaid-style="display" class="js-render-mermaid">graph LR - A[Square Rect] -- Link text --> B((Circle)) - A --> C(Round Rect) - B --> D{Rhombus} - C --> D</pre> - HTML - - expect(render(input, context)).to include(output.strip) - end + output = <<~HTML + <pre data-mermaid-style="display" class="js-render-mermaid">classDiagram + Class01 <|-- AveryLongClass : Cool</pre> + HTML - it 'applies subs in diagram block' do - input = <<~MD - :class-name: AveryLongClass - - [mermaid,subs=+attributes] - .... - classDiagram - Class01 <|-- {class-name} : Cool - .... - MD - - output = <<~HTML - <pre data-mermaid-style="display" class="js-render-mermaid">classDiagram - Class01 <|-- AveryLongClass : Cool</pre> - HTML - - expect(render(input, context)).to include(output.strip) - end + expect(render(input, context)).to include(output.strip) end + end - context 'with Kroki enabled' do - before do - allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') - end - - it 'converts a graphviz diagram to image' do - input = <<~ADOC - [graphviz] - .... - digraph G { - Hello->World - } - .... - ADOC + context 'with Kroki enabled' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) + allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') + end - output = <<~HTML - <div> - <div> - <a class="no-attachment-icon" href="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" class="lazy" data-src="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka"></a> - </div> - </div> - HTML + it 'converts a graphviz diagram to image' do + input = <<~ADOC + [graphviz] + .... + digraph G { + Hello->World + } + .... + ADOC - expect(render(input, context)).to include(output.strip) - end + output = <<~HTML + <div> + <div> + <a class="no-attachment-icon" href="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" class="lazy" data-src="https://kroki.io/graphviz/svg/eNpLyUwvSizIUHBXqOZSUPBIzcnJ17ULzy_KSeGqBQCEzQka"></a> + </div> + </div> + HTML - it 'does not convert a blockdiag diagram to image' do - input = <<~ADOC - [blockdiag] - .... - blockdiag { - Kroki -> generates -> "Block diagrams"; - Kroki -> is -> "very easy!"; - - Kroki [color = "greenyellow"]; - "Block diagrams" [color = "pink"]; - "very easy!" [color = "orange"]; - } - .... - ADOC + expect(render(input, context)).to include(output.strip) + end - output = <<~HTML - <div> - <div> - <pre>blockdiag { - Kroki -> generates -> "Block diagrams"; - Kroki -> is -> "very easy!"; - - Kroki [color = "greenyellow"]; - "Block diagrams" [color = "pink"]; - "very easy!" [color = "orange"]; - }</pre> - </div> - </div> - HTML - - expect(render(input, context)).to include(output.strip) - end + it 'does not convert a blockdiag diagram to image' do + input = <<~ADOC + [blockdiag] + .... + blockdiag { + Kroki -> generates -> "Block diagrams"; + Kroki -> is -> "very easy!"; + + Kroki [color = "greenyellow"]; + "Block diagrams" [color = "pink"]; + "very easy!" [color = "orange"]; + } + .... + ADOC - it 'does not allow kroki-plantuml-include to be overridden' do - input = <<~ADOC - [plantuml, test="{counter:kroki-plantuml-include:/etc/passwd}", format="png"] - .... - class BlockProcessor - - BlockProcessor <|-- {counter:kroki-plantuml-include} - .... - ADOC + output = <<~HTML + <div> + <div> + <pre>blockdiag { + Kroki -> generates -> "Block diagrams"; + Kroki -> is -> "very easy!"; + + Kroki [color = "greenyellow"]; + "Block diagrams" [color = "pink"]; + "very easy!" [color = "orange"]; + }</pre> + </div> + </div> + HTML + + expect(render(input, context)).to include(output.strip) + end - output = <<~HTML - <div> - <div> - <a class=\"no-attachment-icon\" href=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Diagram\" class=\"lazy\" data-src=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\"></a> - </div> - </div> - HTML + it 'does not allow kroki-plantuml-include to be overridden' do + input = <<~ADOC + [plantuml, test="{counter:kroki-plantuml-include:/etc/passwd}", format="png"] + .... + class BlockProcessor - expect(render(input, {})).to include(output.strip) - end + BlockProcessor <|-- {counter:kroki-plantuml-include} + .... + ADOC - it 'does not allow kroki-server-url to be overridden' do - input = <<~ADOC - [plantuml, test="{counter:kroki-server-url:evilsite}", format="png"] - .... - class BlockProcessor - - BlockProcessor - .... - ADOC + output = <<~HTML + <div> + <div> + <a class=\"no-attachment-icon\" href=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\" target=\"_blank\" rel=\"noopener noreferrer\"><img src=\"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==\" alt=\"Diagram\" class=\"lazy\" data-src=\"https://kroki.io/plantuml/png/eNpLzkksLlZwyslPzg4oyk9OLS7OL-LiQuUr2NTo6ipUJ-eX5pWkFlllF-VnZ-oW5CTmlZTm5uhm5iXnlKak1gIABQEb8A==\"></a> + </div> + </div> + HTML - expect(render(input, {})).not_to include('evilsite') - end + expect(render(input, {})).to include(output.strip) end - context 'with Kroki and BlockDiag (additional format) enabled' do - before do - allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) - allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') - allow_any_instance_of(ApplicationSetting).to receive(:kroki_formats_blockdiag).and_return(true) - end - - it 'converts a blockdiag diagram to image' do - input = <<~ADOC - [blockdiag] - .... - blockdiag { - Kroki -> generates -> "Block diagrams"; - Kroki -> is -> "very easy!"; - - Kroki [color = "greenyellow"]; - "Block diagrams" [color = "pink"]; - "very easy!" [color = "orange"]; - } - .... - ADOC + it 'does not allow kroki-server-url to be overridden' do + input = <<~ADOC + [plantuml, test="{counter:kroki-server-url:evilsite}", format="png"] + .... + class BlockProcessor - output = <<~HTML - <div> - <div> - <a class="no-attachment-icon" href="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w==" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" class="lazy" data-src="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w=="></a> - </div> - </div> - HTML + BlockProcessor + .... + ADOC - expect(render(input, context)).to include(output.strip) - end + expect(render(input, {})).not_to include('evilsite') end end - context 'with project' do - let(:context) do - { - commit: commit, - project: project, - ref: ref, - requested_path: requested_path - } + context 'with Kroki and BlockDiag (additional format) enabled' do + before do + allow_any_instance_of(ApplicationSetting).to receive(:kroki_enabled).and_return(true) + allow_any_instance_of(ApplicationSetting).to receive(:kroki_url).and_return('https://kroki.io') + allow_any_instance_of(ApplicationSetting).to receive(:kroki_formats_blockdiag).and_return(true) end - let(:commit) { project.commit(ref) } - let(:project) { create(:project, :repository) } - let(:ref) { 'asciidoc' } - let(:requested_path) { '/' } + it 'converts a blockdiag diagram to image' do + input = <<~ADOC + [blockdiag] + .... + blockdiag { + Kroki -> generates -> "Block diagrams"; + Kroki -> is -> "very easy!"; + + Kroki [color = "greenyellow"]; + "Block diagrams" [color = "pink"]; + "very easy!" [color = "orange"]; + } + .... + ADOC - context 'include directive' do - subject(:output) { render(input, context) } + output = <<~HTML + <div> + <div> + <a class="no-attachment-icon" href="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w==" target="_blank" rel="noopener noreferrer"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="Diagram" class="lazy" data-src="https://kroki.io/blockdiag/svg/eNpdzDEKQjEQhOHeU4zpPYFoYesRxGJ9bwghMSsbUYJ4d10UCZbDfPynolOek0Q8FsDeNCestoisNLmy-Qg7R3Blcm5hPcr0ITdaB6X15fv-_YdJixo2CNHI2lmK3sPRA__RwV5SzV80ZAegJjXSyfMFptc71w=="></a> + </div> + </div> + HTML - let(:input) { "Include this:\n\ninclude::#{include_path}[]" } + expect(render(input, context)).to include(output.strip) + end + end + end - before do - current_file = requested_path - current_file += 'README.adoc' if requested_path.end_with? '/' + context 'with project' do + let(:context) do + { + commit: commit, + project: project, + ref: ref, + requested_path: requested_path + } + end - create_file(current_file, "= AsciiDoc\n") - end + let(:commit) { project.commit(ref) } + let(:project) { create(:project, :repository) } + let(:ref) { 'asciidoc' } + let(:requested_path) { '/' } - def many_includes(target) - Array.new(10, "include::#{target}[]").join("\n") - end + context 'include directive' do + subject(:output) { render(input, context) } - context 'cyclic imports' do - before do - create_file('doc/api/a.adoc', many_includes('b.adoc')) - create_file('doc/api/b.adoc', many_includes('a.adoc')) - end + let(:input) { "Include this:\n\ninclude::#{include_path}[]" } - let(:include_path) { 'a.adoc' } - let(:requested_path) { 'doc/api/README.md' } + before do + current_file = requested_path + current_file += 'README.adoc' if requested_path.end_with? '/' - it 'completes successfully' do - is_expected.to include('<p>Include this:</p>') - end + create_file(current_file, "= AsciiDoc\n") + end + + def many_includes(target) + Array.new(10, "include::#{target}[]").join("\n") + end + + context 'cyclic imports' do + before do + create_file('doc/api/a.adoc', many_includes('b.adoc')) + create_file('doc/api/b.adoc', many_includes('a.adoc')) end - context 'with path to non-existing file' do - let(:include_path) { 'not-exists.adoc' } + let(:include_path) { 'a.adoc' } + let(:requested_path) { 'doc/api/README.md' } - it 'renders Unresolved directive placeholder' do - is_expected.to include("<strong>[ERROR: include::#{include_path}[] - unresolved directive]</strong>") - end + it 'completes successfully' do + is_expected.to include('<p>Include this:</p>') end + end - shared_examples :invalid_include do - let(:include_path) { 'dk.png' } + context 'with path to non-existing file' do + let(:include_path) { 'not-exists.adoc' } - before do - allow(project.repository).to receive(:blob_at).and_return(blob) - end + it 'renders Unresolved directive placeholder' do + is_expected.to include("<strong>[ERROR: include::#{include_path}[] - unresolved directive]</strong>") + end + end - it 'does not read the blob' do - expect(blob).not_to receive(:data) - end + shared_examples :invalid_include do + let(:include_path) { 'dk.png' } - it 'renders Unresolved directive placeholder' do - is_expected.to include("<strong>[ERROR: include::#{include_path}[] - unresolved directive]</strong>") - end + before do + allow(project.repository).to receive(:blob_at).and_return(blob) end - context 'with path to a binary file' do - let(:blob) { fake_blob(path: 'dk.png', binary: true) } + it 'does not read the blob' do + expect(blob).not_to receive(:data) + end - include_examples :invalid_include + it 'renders Unresolved directive placeholder' do + is_expected.to include("<strong>[ERROR: include::#{include_path}[] - unresolved directive]</strong>") end + end - context 'with path to file in external storage' do - let(:blob) { fake_blob(path: 'dk.png', lfs: true) } + context 'with path to a binary file' do + let(:blob) { fake_blob(path: 'dk.png', binary: true) } - before do - allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) - project.update_attribute(:lfs_enabled, true) - end + include_examples :invalid_include + end - include_examples :invalid_include + context 'with path to file in external storage' do + let(:blob) { fake_blob(path: 'dk.png', lfs: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + project.update_attribute(:lfs_enabled, true) end - context 'with path to a textual file' do - let(:include_path) { 'sample.adoc' } + include_examples :invalid_include + end - before do - create_file(file_path, "Content from #{include_path}") - end + context 'with path to a textual file' do + let(:include_path) { 'sample.adoc' } - shared_examples :valid_include do - [ - ['/doc/sample.adoc', 'doc/sample.adoc', 'absolute path'], - ['sample.adoc', 'doc/api/sample.adoc', 'relative path'], - ['./sample.adoc', 'doc/api/sample.adoc', 'relative path with leading ./'], - ['../sample.adoc', 'doc/sample.adoc', 'relative path to a file up one directory'], - ['../../sample.adoc', 'sample.adoc', 'relative path for a file up multiple directories'] - ].each do |include_path_, file_path_, desc| - context "the file is specified by #{desc}" do - let(:include_path) { include_path_ } - let(:file_path) { file_path_ } - - it 'includes content of the file' do - is_expected.to include('<p>Include this:</p>') - is_expected.to include("<p>Content from #{include_path}</p>") - end + before do + create_file(file_path, "Content from #{include_path}") + end + + shared_examples :valid_include do + [ + ['/doc/sample.adoc', 'doc/sample.adoc', 'absolute path'], + ['sample.adoc', 'doc/api/sample.adoc', 'relative path'], + ['./sample.adoc', 'doc/api/sample.adoc', 'relative path with leading ./'], + ['../sample.adoc', 'doc/sample.adoc', 'relative path to a file up one directory'], + ['../../sample.adoc', 'sample.adoc', 'relative path for a file up multiple directories'] + ].each do |include_path_, file_path_, desc| + context "the file is specified by #{desc}" do + let(:include_path) { include_path_ } + let(:file_path) { file_path_ } + + it 'includes content of the file' do + is_expected.to include('<p>Include this:</p>') + is_expected.to include("<p>Content from #{include_path}</p>") end end end + end - context 'when requested path is a file in the repo' do - let(:requested_path) { 'doc/api/README.adoc' } + context 'when requested path is a file in the repo' do + let(:requested_path) { 'doc/api/README.adoc' } - include_examples :valid_include + include_examples :valid_include - context 'without a commit (only ref)' do - let(:commit) { nil } + context 'without a commit (only ref)' do + let(:commit) { nil } - include_examples :valid_include - end + include_examples :valid_include end + end - context 'when requested path is a directory in the repo' do - let(:requested_path) { 'doc/api/' } + context 'when requested path is a directory in the repo' do + let(:requested_path) { 'doc/api/' } - include_examples :valid_include + include_examples :valid_include - context 'without a commit (only ref)' do - let(:commit) { nil } + context 'without a commit (only ref)' do + let(:commit) { nil } - include_examples :valid_include - end + include_examples :valid_include end end + end - context 'when repository is passed into the context' do - let(:wiki_repo) { project.wiki.repository } - let(:include_path) { 'wiki_file.adoc' } + context 'when repository is passed into the context' do + let(:wiki_repo) { project.wiki.repository } + let(:include_path) { 'wiki_file.adoc' } + before do + project.create_wiki + context.merge!(repository: wiki_repo) + end + + context 'when the file exists' do before do - project.create_wiki - context.merge!(repository: wiki_repo) + create_file(include_path, 'Content from wiki', repository: wiki_repo) end - context 'when the file exists' do - before do - create_file(include_path, 'Content from wiki', repository: wiki_repo) - end + it { is_expected.to include('<p>Content from wiki</p>') } + end - it { is_expected.to include('<p>Content from wiki</p>') } - end + context 'when the file does not exist' do + it { is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]")} + end + end - context 'when the file does not exist' do - it { is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]")} - end + context 'recursive includes with relative paths' do + let(:input) do + <<~ADOC + Source: requested file + + include::doc/README.adoc[] + + include::license.adoc[] + ADOC end - context 'recursive includes with relative paths' do - let(:input) do - <<~ADOC - Source: requested file - - include::doc/README.adoc[] - - include::license.adoc[] - ADOC - end + before do + create_file 'doc/README.adoc', <<~ADOC + Source: doc/README.adoc - before do - create_file 'doc/README.adoc', <<~ADOC - Source: doc/README.adoc - - include::../license.adoc[] - - include::api/hello.adoc[] - ADOC - create_file 'license.adoc', <<~ADOC - Source: license.adoc - ADOC - create_file 'doc/api/hello.adoc', <<~ADOC - Source: doc/api/hello.adoc - - include::./common.adoc[] - ADOC - create_file 'doc/api/common.adoc', <<~ADOC - Source: doc/api/common.adoc - ADOC - end + include::../license.adoc[] - it 'includes content of the included files recursively' do - expect(output.gsub(/<[^>]+>/, '').gsub(/\n\s*/, "\n").strip).to eq <<~ADOC.strip - Source: requested file - Source: doc/README.adoc - Source: license.adoc - Source: doc/api/hello.adoc - Source: doc/api/common.adoc - Source: license.adoc - ADOC - end + include::api/hello.adoc[] + ADOC + create_file 'license.adoc', <<~ADOC + Source: license.adoc + ADOC + create_file 'doc/api/hello.adoc', <<~ADOC + Source: doc/api/hello.adoc + + include::./common.adoc[] + ADOC + create_file 'doc/api/common.adoc', <<~ADOC + Source: doc/api/common.adoc + ADOC end - def create_file(path, content, repository: project.repository) - repository.create_file(project.creator, path, content, - message: "Add #{path}", branch_name: 'asciidoc') + it 'includes content of the included files recursively' do + expect(output.gsub(/<[^>]+>/, '').gsub(/\n\s*/, "\n").strip).to eq <<~ADOC.strip + Source: requested file + Source: doc/README.adoc + Source: license.adoc + Source: doc/api/hello.adoc + Source: doc/api/common.adoc + Source: license.adoc + ADOC end end - end - end - context 'using ruby-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: false) - end - - it_behaves_like 'renders correct asciidoc' - end - - context 'using c-based HTML renderer' do - before do - stub_feature_flags(use_cmark_renderer: true) + def create_file(path, content, repository: project.repository) + repository.create_file(project.creator, path, content, + message: "Add #{path}", branch_name: 'asciidoc') + end end - - it_behaves_like 'renders correct asciidoc' end def render(*args) diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index f1c891b2adb..e985f66bfe9 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -939,21 +939,19 @@ RSpec.describe Gitlab::Auth::AuthFinders do end describe '#cluster_agent_token_from_authorization_token' do - let_it_be(:agent_token, freeze: true) { create(:cluster_agent_token) } + let_it_be(:agent_token) { create(:cluster_agent_token) } + + subject { cluster_agent_token_from_authorization_token } context 'when route_setting is empty' do - it 'returns nil' do - expect(cluster_agent_token_from_authorization_token).to be_nil - end + it { is_expected.to be_nil } end context 'when route_setting allows cluster agent token' do let(:route_authentication_setting) { { cluster_agent_token_allowed: true } } context 'Authorization header is empty' do - it 'returns nil' do - expect(cluster_agent_token_from_authorization_token).to be_nil - end + it { is_expected.to be_nil } end context 'Authorization header is incorrect' do @@ -961,9 +959,7 @@ RSpec.describe Gitlab::Auth::AuthFinders do request.headers['Authorization'] = 'Bearer ABCD' end - it 'returns nil' do - expect(cluster_agent_token_from_authorization_token).to be_nil - end + it { is_expected.to be_nil } end context 'Authorization header is malformed' do @@ -971,9 +967,7 @@ RSpec.describe Gitlab::Auth::AuthFinders do request.headers['Authorization'] = 'Bearer' end - it 'returns nil' do - expect(cluster_agent_token_from_authorization_token).to be_nil - end + it { is_expected.to be_nil } end context 'Authorization header matches agent token' do @@ -981,8 +975,14 @@ RSpec.describe Gitlab::Auth::AuthFinders do request.headers['Authorization'] = "Bearer #{agent_token.token}" end - it 'returns the agent token' do - expect(cluster_agent_token_from_authorization_token).to eq(agent_token) + it { is_expected.to eq(agent_token) } + + context 'agent token has been revoked' do + before do + agent_token.revoked! + end + + it { is_expected.to be_nil } end end end diff --git a/spec/lib/gitlab/auth/ldap/config_spec.rb b/spec/lib/gitlab/auth/ldap/config_spec.rb index 7a657cce597..3039fce6141 100644 --- a/spec/lib/gitlab/auth/ldap/config_spec.rb +++ b/spec/lib/gitlab/auth/ldap/config_spec.rb @@ -121,10 +121,40 @@ AtlErSqafbECNDSwS5BX8yDpu5yRBJ4xegO/rNlmb8ICRYkuJapD1xXicFOsmfUK expect(config.adapter_options).to eq( host: 'ldap.example.com', port: 386, + hosts: nil, encryption: nil ) end + it 'includes failover hosts when set' do + stub_ldap_config( + options: { + 'host' => 'ldap.example.com', + 'port' => 686, + 'hosts' => [ + ['ldap1.example.com', 636], + ['ldap2.example.com', 636] + ], + 'encryption' => 'simple_tls', + 'verify_certificates' => true, + 'bind_dn' => 'uid=admin,dc=example,dc=com', + 'password' => 'super_secret' + } + ) + + expect(config.adapter_options).to include({ + hosts: [ + ['ldap1.example.com', 636], + ['ldap2.example.com', 636] + ], + auth: { + method: :simple, + username: 'uid=admin,dc=example,dc=com', + password: 'super_secret' + } + }) + end + it 'includes authentication options when auth is configured' do stub_ldap_config( options: { diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 32e647688ff..611c70d73a1 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -87,7 +87,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do end context 'when IP is already banned' do - subject { gl_auth.find_for_git_client('username', 'password', project: nil, ip: 'ip') } + subject { gl_auth.find_for_git_client('username', Gitlab::Password.test_default, project: nil, ip: 'ip') } before do expect_next_instance_of(Gitlab::Auth::IpRateLimiter) do |rate_limiter| @@ -204,16 +204,16 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do end it 'recognizes master passwords' do - user = create(:user, password: 'password') + user = create(:user, password: Gitlab::Password.test_default) - expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to have_attributes(actor: user, project: nil, type: :gitlab_or_ldap, authentication_abilities: described_class.full_authentication_abilities) + expect(gl_auth.find_for_git_client(user.username, Gitlab::Password.test_default, project: nil, ip: 'ip')).to have_attributes(actor: user, project: nil, type: :gitlab_or_ldap, authentication_abilities: described_class.full_authentication_abilities) end include_examples 'user login operation with unique ip limit' do - let(:user) { create(:user, password: 'password') } + let(:user) { create(:user, password: Gitlab::Password.test_default) } def operation - expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to have_attributes(actor: user, project: nil, type: :gitlab_or_ldap, authentication_abilities: described_class.full_authentication_abilities) + expect(gl_auth.find_for_git_client(user.username, Gitlab::Password.test_default, project: nil, ip: 'ip')).to have_attributes(actor: user, project: nil, type: :gitlab_or_ldap, authentication_abilities: described_class.full_authentication_abilities) end end @@ -477,7 +477,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do :user, :blocked, username: 'normal_user', - password: 'my-secret' + password: Gitlab::Password.test_default ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) @@ -486,7 +486,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do context 'when 2fa is enabled globally' do let_it_be(:user) do - create(:user, username: 'normal_user', password: 'my-secret', otp_grace_period_started_at: 1.day.ago) + create(:user, username: 'normal_user', password: Gitlab::Password.test_default, otp_grace_period_started_at: 1.day.ago) end before do @@ -510,7 +510,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do context 'when 2fa is enabled personally' do let(:user) do - create(:user, :two_factor, username: 'normal_user', password: 'my-secret', otp_grace_period_started_at: 1.day.ago) + create(:user, :two_factor, username: 'normal_user', password: Gitlab::Password.test_default, otp_grace_period_started_at: 1.day.ago) end it 'fails' do @@ -523,7 +523,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do user = create( :user, username: 'normal_user', - password: 'my-secret' + password: Gitlab::Password.test_default ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) @@ -534,7 +534,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do user = create( :user, username: 'oauth2', - password: 'my-secret' + password: Gitlab::Password.test_default ) expect(gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip')) @@ -609,7 +609,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do context 'when deploy token and user have the same username' do let(:username) { 'normal_user' } - let(:user) { create(:user, username: username, password: 'my-secret') } + let(:user) { create(:user, username: username, password: Gitlab::Password.test_default) } let(:deploy_token) { create(:deploy_token, username: username, read_registry: false, projects: [project]) } it 'succeeds for the token' do @@ -622,7 +622,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do it 'succeeds for the user' do auth_success = { actor: user, project: nil, type: :gitlab_or_ldap, authentication_abilities: described_class.full_authentication_abilities } - expect(gl_auth.find_for_git_client(username, 'my-secret', project: project, ip: 'ip')) + expect(gl_auth.find_for_git_client(username, Gitlab::Password.test_default, project: project, ip: 'ip')) .to have_attributes(auth_success) end end @@ -816,7 +816,7 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do end let(:username) { 'John' } # username isn't lowercase, test this - let(:password) { 'my-secret' } + let(:password) { Gitlab::Password.test_default } it "finds user by valid login/password" do expect(gl_auth.find_with_user_password(username, password)).to eql user @@ -941,13 +941,13 @@ RSpec.describe Gitlab::Auth, :use_clean_rails_memory_store_caching do 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 + expect(gl_auth.find_with_user_password('ldap_user', Gitlab::Password.test_default)).to be_nil end it "find new user by using ldap as fallback to for authentication" do expect(Gitlab::Auth::Ldap::Authentication).to receive(:login).and_return(user) - expect(gl_auth.find_with_user_password('ldap_user', 'password')).to eq(user) + expect(gl_auth.find_with_user_password('ldap_user', Gitlab::Password.test_default)).to eq(user) end end diff --git a/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb b/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb index 6ab1e3ecd70..f5d2224747a 100644 --- a/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_artifact_expiry_date_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillArtifactExpiryDate, :migration, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::BackfillArtifactExpiryDate, :migration, schema: 20210301200959 do subject(:perform) { migration.perform(1, 99) } let(:migration) { described_class.new } diff --git a/spec/lib/gitlab/background_migration/backfill_ci_namespace_mirrors_spec.rb b/spec/lib/gitlab/background_migration/backfill_ci_namespace_mirrors_spec.rb new file mode 100644 index 00000000000..8980a26932b --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_ci_namespace_mirrors_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillCiNamespaceMirrors, :migration, schema: 20211208122200 do + let(:namespaces) { table(:namespaces) } + let(:ci_namespace_mirrors) { table(:ci_namespace_mirrors) } + + subject { described_class.new } + + describe '#perform' do + it 'creates hierarchies for all namespaces in range' do + namespaces.create!(id: 5, name: 'test1', path: 'test1') + namespaces.create!(id: 7, name: 'test2', path: 'test2') + namespaces.create!(id: 8, name: 'test3', path: 'test3') + + subject.perform(5, 7) + + expect(ci_namespace_mirrors.all).to contain_exactly( + an_object_having_attributes(namespace_id: 5, traversal_ids: [5]), + an_object_having_attributes(namespace_id: 7, traversal_ids: [7]) + ) + end + + it 'handles existing hierarchies gracefully' do + namespaces.create!(id: 5, name: 'test1', path: 'test1') + test2 = namespaces.create!(id: 7, name: 'test2', path: 'test2') + namespaces.create!(id: 8, name: 'test3', path: 'test3', parent_id: 7) + namespaces.create!(id: 9, name: 'test4', path: 'test4') + + # Simulate a situation where a user has had a chance to move a group to another parent + # before the background migration has had a chance to run + test2.update!(parent_id: 5) + ci_namespace_mirrors.create!(namespace_id: test2.id, traversal_ids: [5, 7]) + + subject.perform(5, 8) + + expect(ci_namespace_mirrors.all).to contain_exactly( + an_object_having_attributes(namespace_id: 5, traversal_ids: [5]), + an_object_having_attributes(namespace_id: 7, traversal_ids: [5, 7]), + an_object_having_attributes(namespace_id: 8, traversal_ids: [5, 7, 8]) + ) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_ci_project_mirrors_spec.rb b/spec/lib/gitlab/background_migration/backfill_ci_project_mirrors_spec.rb new file mode 100644 index 00000000000..4eec83879e3 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_ci_project_mirrors_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillCiProjectMirrors, :migration, schema: 20211208122201 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:ci_project_mirrors) { table(:ci_project_mirrors) } + + subject { described_class.new } + + describe '#perform' do + it 'creates ci_project_mirrors for all projects in range' do + namespaces.create!(id: 10, name: 'namespace1', path: 'namespace1') + projects.create!(id: 5, namespace_id: 10, name: 'test1', path: 'test1') + projects.create!(id: 7, namespace_id: 10, name: 'test2', path: 'test2') + projects.create!(id: 8, namespace_id: 10, name: 'test3', path: 'test3') + + subject.perform(5, 7) + + expect(ci_project_mirrors.all).to contain_exactly( + an_object_having_attributes(project_id: 5, namespace_id: 10), + an_object_having_attributes(project_id: 7, namespace_id: 10) + ) + end + + it 'handles existing ci_project_mirrors gracefully' do + namespaces.create!(id: 10, name: 'namespace1', path: 'namespace1') + namespaces.create!(id: 11, name: 'namespace2', path: 'namespace2', parent_id: 10) + projects.create!(id: 5, namespace_id: 10, name: 'test1', path: 'test1') + projects.create!(id: 7, namespace_id: 11, name: 'test2', path: 'test2') + projects.create!(id: 8, namespace_id: 11, name: 'test3', path: 'test3') + + # Simulate a situation where a user has had a chance to move a project to another namespace + # before the background migration has had a chance to run + ci_project_mirrors.create!(project_id: 7, namespace_id: 10) + + subject.perform(5, 7) + + expect(ci_project_mirrors.all).to contain_exactly( + an_object_having_attributes(project_id: 5, namespace_id: 10), + an_object_having_attributes(project_id: 7, namespace_id: 10) + ) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb b/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb new file mode 100644 index 00000000000..242da383453 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillIncidentIssueEscalationStatuses, schema: 20211214012507 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:issues) { table(:issues) } + let(:issuable_escalation_statuses) { table(:incident_management_issuable_escalation_statuses) } + + subject(:migration) { described_class.new } + + it 'correctly backfills issuable escalation status records' do + namespace = namespaces.create!(name: 'foo', path: 'foo') + project = projects.create!(namespace_id: namespace.id) + + issues.create!(project_id: project.id, title: 'issue 1', issue_type: 0) # non-incident issue + issues.create!(project_id: project.id, title: 'incident 1', issue_type: 1) + issues.create!(project_id: project.id, title: 'incident 2', issue_type: 1) + incident_issue_existing_status = issues.create!(project_id: project.id, title: 'incident 3', issue_type: 1) + issuable_escalation_statuses.create!(issue_id: incident_issue_existing_status.id) + + migration.perform(1, incident_issue_existing_status.id) + + expect(issuable_escalation_statuses.count).to eq(3) + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb b/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb index 446d62bbd2a..65f5f8368df 100644 --- a/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillJiraTrackerDeploymentType2, :migration, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::BackfillJiraTrackerDeploymentType2, :migration, schema: 20210301200959 do let_it_be(:jira_integration_temp) { described_class::JiraServiceTemp } let_it_be(:jira_tracker_data_temp) { described_class::JiraTrackerDataTemp } let_it_be(:atlassian_host) { 'https://api.atlassian.net' } diff --git a/spec/lib/gitlab/background_migration/backfill_project_updated_at_after_repository_storage_move_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_updated_at_after_repository_storage_move_spec.rb index 708e5e21dbe..ed44b819a97 100644 --- a/spec/lib/gitlab/background_migration/backfill_project_updated_at_after_repository_storage_move_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_project_updated_at_after_repository_storage_move_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillProjectUpdatedAtAfterRepositoryStorageMove, :migration, schema: 20210210093901 do +RSpec.describe Gitlab::BackgroundMigration::BackfillProjectUpdatedAtAfterRepositoryStorageMove, :migration, schema: 20210301200959 do let(:projects) { table(:projects) } let(:project_repository_storage_moves) { table(:project_repository_storage_moves) } let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } diff --git a/spec/lib/gitlab/background_migration/base_job_spec.rb b/spec/lib/gitlab/background_migration/base_job_spec.rb new file mode 100644 index 00000000000..86abe4257e4 --- /dev/null +++ b/spec/lib/gitlab/background_migration/base_job_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BaseJob, '#perform' do + let(:connection) { double(:connection) } + + let(:test_job_class) { Class.new(described_class) } + let(:test_job) { test_job_class.new(connection: connection) } + + describe '#perform' do + it 'raises an error if not overridden by a subclass' do + expect { test_job.perform }.to raise_error(NotImplementedError, /must implement perform/) + end + end +end diff --git a/spec/lib/gitlab/background_migration/cleanup_concurrent_schema_change_spec.rb b/spec/lib/gitlab/background_migration/cleanup_concurrent_schema_change_spec.rb deleted file mode 100644 index 2931b5e6dd3..00000000000 --- a/spec/lib/gitlab/background_migration/cleanup_concurrent_schema_change_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::CleanupConcurrentSchemaChange do - describe '#perform' do - it 'new column does not exist' do - expect(subject).to receive(:column_exists?).with(:issues, :closed_at_timestamp).and_return(false) - expect(subject).not_to receive(:column_exists?).with(:issues, :closed_at) - expect(subject).not_to receive(:define_model_for) - - expect(subject.perform(:issues, :closed_at, :closed_at_timestamp)).to be_nil - end - - it 'old column does not exist' do - expect(subject).to receive(:column_exists?).with(:issues, :closed_at_timestamp).and_return(true) - expect(subject).to receive(:column_exists?).with(:issues, :closed_at).and_return(false) - expect(subject).not_to receive(:define_model_for) - - expect(subject.perform(:issues, :closed_at, :closed_at_timestamp)).to be_nil - end - - it 'has both old and new columns' do - expect(subject).to receive(:column_exists?).twice.and_return(true) - - expect { subject.perform('issues', :closed_at, :created_at) }.to raise_error(NotImplementedError) - end - end -end diff --git a/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb index b83dc6fff7a..5b6722a3384 100644 --- a/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb +++ b/spec/lib/gitlab/background_migration/drop_invalid_vulnerabilities_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::DropInvalidVulnerabilities, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::DropInvalidVulnerabilities, schema: 20210301200959 do let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } let_it_be(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let_it_be(:users) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb new file mode 100644 index 00000000000..94d9f4509a7 --- /dev/null +++ b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::EncryptStaticObjectToken do + let(:users) { table(:users) } + let!(:user_without_tokens) { create_user!(name: 'notoken') } + let!(:user_with_plaintext_token_1) { create_user!(name: 'plaintext_1', token: 'token') } + let!(:user_with_plaintext_token_2) { create_user!(name: 'plaintext_2', token: 'TOKEN') } + let!(:user_with_plaintext_empty_token) { create_user!(name: 'plaintext_3', token: '') } + let!(:user_with_encrypted_token) { create_user!(name: 'encrypted', encrypted_token: 'encrypted') } + let!(:user_with_both_tokens) { create_user!(name: 'both', token: 'token2', encrypted_token: 'encrypted2') } + + before do + allow(Gitlab::CryptoHelper).to receive(:aes256_gcm_encrypt).and_call_original + allow(Gitlab::CryptoHelper).to receive(:aes256_gcm_encrypt).with('token') { 'secure_token' } + allow(Gitlab::CryptoHelper).to receive(:aes256_gcm_encrypt).with('TOKEN') { 'SECURE_TOKEN' } + end + + subject { described_class.new.perform(start_id, end_id) } + + let(:start_id) { users.minimum(:id) } + let(:end_id) { users.maximum(:id) } + + it 'backfills encrypted tokens to users with plaintext token only', :aggregate_failures do + subject + + new_state = users.pluck(:id, :static_object_token, :static_object_token_encrypted).to_h do |row| + [row[0], [row[1], row[2]]] + end + + expect(new_state.count).to eq(6) + + expect(new_state[user_with_plaintext_token_1.id]).to match_array(%w[token secure_token]) + expect(new_state[user_with_plaintext_token_2.id]).to match_array(%w[TOKEN SECURE_TOKEN]) + + expect(new_state[user_with_plaintext_empty_token.id]).to match_array(['', nil]) + expect(new_state[user_without_tokens.id]).to match_array([nil, nil]) + expect(new_state[user_with_both_tokens.id]).to match_array(%w[token2 encrypted2]) + expect(new_state[user_with_encrypted_token.id]).to match_array([nil, 'encrypted']) + end + + private + + def create_user!(name:, token: nil, encrypted_token: nil) + email = "#{name}@example.com" + + table(:users).create!( + name: name, + email: email, + username: name, + projects_limit: 0, + static_object_token: token, + static_object_token_encrypted: encrypted_token + ) + end +end diff --git a/spec/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata_spec.rb b/spec/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata_spec.rb new file mode 100644 index 00000000000..af551861d47 --- /dev/null +++ b/spec/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata_spec.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::FixVulnerabilityOccurrencesWithHashesAsRawMetadata, schema: 20211209203821 do + let(:users) { table(:users) } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:scanners) { table(:vulnerability_scanners) } + let(:identifiers) { table(:vulnerability_identifiers) } + let(:findings) { table(:vulnerability_occurrences) } + + let(:user) { users.create!(name: 'Test User', projects_limit: 10, username: 'test-user', email: '1') } + + let(:namespace) do + namespaces.create!( + owner_id: user.id, + name: user.name, + path: user.username + ) + end + + let(:project) do + projects.create!(namespace_id: namespace.id, name: 'Test Project') + end + + let(:scanner) do + scanners.create!( + project_id: project.id, + external_id: 'test-scanner', + name: 'Test Scanner', + vendor: 'GitLab' + ) + end + + let(:primary_identifier) do + identifiers.create!( + project_id: project.id, + external_type: 'cve', + name: 'CVE-2021-1234', + external_id: 'CVE-2021-1234', + fingerprint: '4c0fe491999f94701ee437588554ef56322ae276' + ) + end + + let(:finding) do + findings.create!( + raw_metadata: raw_metadata, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: primary_identifier.id, + uuid: '4deb090a-bedf-5ccc-aa9a-ac8055a1ea81', + project_fingerprint: '1caa750a6dad769a18ad6f40b413b3b6ab1c8d77', + location_fingerprint: '6d1f35f53b065238abfcadc01336ce65d112a2bd', + name: 'name', + report_type: 7, + severity: 0, + confidence: 0, + detection_method: 'gitlab_security_report', + metadata_version: 'cluster_image_scanning:1.0', + created_at: "2021-12-10 14:27:42 -0600", + updated_at: "2021-12-10 14:27:42 -0600" + ) + end + + subject(:perform) { described_class.new.perform(finding.id, finding.id) } + + context 'with stringified hash as raw_metadata' do + let(:raw_metadata) do + '{:location=>{"image"=>"index.docker.io/library/nginx:latest", "kubernetes_resource"=>{"namespace"=>"production", "kind"=>"deployment", "name"=>"nginx", "container_name"=>"nginx", "agent_id"=>"2"}, "dependency"=>{"package"=>{"name"=>"libc"}, "version"=>"v1.2.3"}}}' + end + + it 'converts stringified hash to JSON' do + expect { perform }.not_to raise_error + + result = finding.reload.raw_metadata + metadata = Oj.load(result) + expect(metadata).to eq( + { + 'location' => { + 'image' => 'index.docker.io/library/nginx:latest', + 'kubernetes_resource' => { + 'namespace' => 'production', + 'kind' => 'deployment', + 'name' => 'nginx', + 'container_name' => 'nginx', + 'agent_id' => '2' + }, + 'dependency' => { + 'package' => { 'name' => 'libc' }, + 'version' => 'v1.2.3' + } + } + } + ) + end + end + + context 'with valid raw_metadata' do + where(:raw_metadata) do + [ + '{}', + '{"location":null}', + '{"location":{"image":"index.docker.io/library/nginx:latest","kubernetes_resource":{"namespace":"production","kind":"deployment","name":"nginx","container_name":"nginx","agent_id":"2"},"dependency":{"package":{"name":"libc"},"version":"v1.2.3"}}}' + ] + end + + with_them do + it 'does not change the raw_metadata' do + expect { perform }.not_to raise_error + + result = finding.reload.raw_metadata + expect(result).to eq(raw_metadata) + end + end + end + + context 'when raw_metadata contains forbidden types' do + using RSpec::Parameterized::TableSyntax + + where(:raw_metadata, :type) do + 'def foo; "bar"; end' | :def + '`cat somefile`' | :xstr + 'exec("cat /etc/passwd")' | :send + end + + with_them do + it 'does not change the raw_metadata' do + expect(Gitlab::AppLogger).to receive(:error).with(message: "expected raw_metadata to be a hash", type: type) + + expect { perform }.not_to raise_error + + result = finding.reload.raw_metadata + expect(result).to eq(raw_metadata) + end + end + end + + context 'when forbidden types are nested inside a hash' do + using RSpec::Parameterized::TableSyntax + + where(:raw_metadata, :type) do + '{:location=>Env.fetch("SOME_VAR")}' | :send + '{:location=>{:image=>Env.fetch("SOME_VAR")}}' | :send + # rubocop:disable Lint/InterpolationCheck + '{"key"=>"value: #{send}"}' | :dstr + # rubocop:enable Lint/InterpolationCheck + end + + with_them do + it 'does not change the raw_metadata' do + expect(Gitlab::AppLogger).to receive(:error).with( + message: "error parsing raw_metadata", + error: "value of a pair was an unexpected type", + type: type + ) + + expect { perform }.not_to raise_error + + result = finding.reload.raw_metadata + expect(result).to eq(raw_metadata) + end + end + end + + context 'when key is an unexpected type' do + let(:raw_metadata) { "{nil=>nil}" } + + it 'logs error' do + expect(Gitlab::AppLogger).to receive(:error).with( + message: "error parsing raw_metadata", + error: "expected key to be either symbol, string, or integer", + type: :nil + ) + + expect { perform }.not_to raise_error + end + end + + context 'when raw_metadata cannot be parsed' do + let(:raw_metadata) { "{" } + + it 'logs error' do + expect(Gitlab::AppLogger).to receive(:error).with(message: "error parsing raw_metadata", error: "unexpected token $end") + + expect { perform }.not_to raise_error + end + end + + describe '#hash_from_s' do + subject { described_class.new.hash_from_s(input) } + + context 'with valid input' do + let(:input) { '{:location=>{"image"=>"index.docker.io/library/nginx:latest", "kubernetes_resource"=>{"namespace"=>"production", "kind"=>"deployment", "name"=>"nginx", "container_name"=>"nginx", "agent_id"=>2}, "dependency"=>{"package"=>{"name"=>"libc"}, "version"=>"v1.2.3"}}}' } + + it 'converts string to a hash' do + expect(subject).to eq({ + location: { + 'image' => 'index.docker.io/library/nginx:latest', + 'kubernetes_resource' => { + 'namespace' => 'production', + 'kind' => 'deployment', + 'name' => 'nginx', + 'container_name' => 'nginx', + 'agent_id' => 2 + }, + 'dependency' => { + 'package' => { 'name' => 'libc' }, + 'version' => 'v1.2.3' + } + } + }) + end + end + + using RSpec::Parameterized::TableSyntax + + where(:input, :expected) do + '{}' | {} + '{"bool"=>true}' | { 'bool' => true } + '{"bool"=>false}' | { 'bool' => false } + '{"nil"=>nil}' | { 'nil' => nil } + '{"array"=>[1, "foo", nil]}' | { 'array' => [1, "foo", nil] } + '{foo: :bar}' | { foo: :bar } + '{foo: {bar: "bin"}}' | { foo: { bar: "bin" } } + end + + with_them do + specify { expect(subject).to eq(expected) } + end + end +end diff --git a/spec/lib/gitlab/background_migration/job_coordinator_spec.rb b/spec/lib/gitlab/background_migration/job_coordinator_spec.rb index 7a524d1489a..43d41408e66 100644 --- a/spec/lib/gitlab/background_migration/job_coordinator_spec.rb +++ b/spec/lib/gitlab/background_migration/job_coordinator_spec.rb @@ -202,23 +202,50 @@ RSpec.describe Gitlab::BackgroundMigration::JobCoordinator do end describe '#perform' do - let(:migration) { spy(:migration) } - let(:connection) { double('connection') } + let(:connection) { double(:connection) } before do - stub_const('Gitlab::BackgroundMigration::Foo', migration) - allow(coordinator).to receive(:connection).and_return(connection) end - it 'performs a background migration with the configured shared connection' do - expect(coordinator).to receive(:with_shared_connection).and_call_original + context 'when the background migration does not inherit from BaseJob' do + let(:migration_class) { Class.new } + + before do + stub_const('Gitlab::BackgroundMigration::Foo', migration_class) + end + + it 'performs a background migration with the configured shared connection' do + expect(coordinator).to receive(:with_shared_connection).and_call_original + + expect_next_instance_of(migration_class) do |migration| + expect(migration).to receive(:perform).with(10, 20).once do + expect(Gitlab::Database::SharedModel.connection).to be(connection) + end + end + + coordinator.perform('Foo', [10, 20]) + end + end + + context 'when the background migration inherits from BaseJob' do + let(:migration_class) { Class.new(::Gitlab::BackgroundMigration::BaseJob) } + let(:migration) { double(:migration) } - expect(migration).to receive(:perform).with(10, 20).once do - expect(Gitlab::Database::SharedModel.connection).to be(connection) + before do + stub_const('Gitlab::BackgroundMigration::Foo', migration_class) end - coordinator.perform('Foo', [10, 20]) + it 'passes the correct connection when constructing the migration' do + expect(coordinator).to receive(:with_shared_connection).and_call_original + + expect(migration_class).to receive(:new).with(connection: connection).and_return(migration) + expect(migration).to receive(:perform).with(10, 20).once do + expect(Gitlab::Database::SharedModel.connection).to be(connection) + end + + coordinator.perform('Foo', [10, 20]) + end end end diff --git a/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb b/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb deleted file mode 100644 index 5c93e69b5e5..00000000000 --- a/spec/lib/gitlab/background_migration/migrate_legacy_artifacts_spec.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::MigrateLegacyArtifacts, schema: 20210210093901 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:pipelines) { table(:ci_pipelines) } - let(:jobs) { table(:ci_builds) } - let(:job_artifacts) { table(:ci_job_artifacts) } - - subject { described_class.new.perform(*range) } - - context 'when a pipeline exists' do - let!(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let!(:project) { projects.create!(name: 'gitlab', path: 'gitlab-ce', namespace_id: namespace.id) } - let!(:pipeline) { pipelines.create!(project_id: project.id, ref: 'master', sha: 'adf43c3a') } - - context 'when a legacy artifacts exists' do - let(:artifacts_expire_at) { 1.day.since.to_s } - let(:file_store) { ::ObjectStorage::Store::REMOTE } - - let!(:job) do - jobs.create!( - commit_id: pipeline.id, - project_id: project.id, - status: :success, - **artifacts_archive_attributes, - **artifacts_metadata_attributes) - end - - let(:artifacts_archive_attributes) do - { - artifacts_file: 'archive.zip', - artifacts_file_store: file_store, - artifacts_size: 123, - artifacts_expire_at: artifacts_expire_at - } - end - - let(:artifacts_metadata_attributes) do - { - artifacts_metadata: 'metadata.gz', - artifacts_metadata_store: file_store - } - end - - it 'has legacy artifacts' do - expect(jobs.pluck('artifacts_file, artifacts_file_store, artifacts_size, artifacts_expire_at')).to eq([artifacts_archive_attributes.values]) - expect(jobs.pluck('artifacts_metadata, artifacts_metadata_store')).to eq([artifacts_metadata_attributes.values]) - end - - it 'does not have new artifacts yet' do - expect(job_artifacts.count).to be_zero - end - - context 'when the record exists inside of the range of a background migration' do - let(:range) { [job.id, job.id] } - - it 'migrates a legacy artifact to ci_job_artifacts table' do - expect { subject }.to change { job_artifacts.count }.by(2) - - expect(job_artifacts.order(:id).pluck('project_id, job_id, file_type, file_store, size, expire_at, file, file_sha256, file_location')) - .to eq([[project.id, - job.id, - described_class::ARCHIVE_FILE_TYPE, - file_store, - artifacts_archive_attributes[:artifacts_size], - artifacts_expire_at, - 'archive.zip', - nil, - described_class::LEGACY_PATH_FILE_LOCATION], - [project.id, - job.id, - described_class::METADATA_FILE_TYPE, - file_store, - nil, - artifacts_expire_at, - 'metadata.gz', - nil, - described_class::LEGACY_PATH_FILE_LOCATION]]) - - expect(jobs.pluck('artifacts_file, artifacts_file_store, artifacts_size, artifacts_expire_at')).to eq([[nil, nil, nil, artifacts_expire_at]]) - expect(jobs.pluck('artifacts_metadata, artifacts_metadata_store')).to eq([[nil, nil]]) - end - - context 'when file_store is nil' do - let(:file_store) { nil } - - it 'has nullified file_store in all legacy artifacts' do - expect(jobs.pluck('artifacts_file_store, artifacts_metadata_store')).to eq([[nil, nil]]) - end - - it 'fills file_store by the value of local file store' do - subject - - expect(job_artifacts.pluck('file_store')).to all(eq(::ObjectStorage::Store::LOCAL)) - end - end - - context 'when new artifacts has already existed' do - context 'when only archive.zip existed' do - before do - job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: described_class::ARCHIVE_FILE_TYPE, size: 999, file: 'archive.zip') - end - - it 'had archive.zip already' do - expect(job_artifacts.exists?(job_id: job.id, file_type: described_class::ARCHIVE_FILE_TYPE)).to be_truthy - end - - it 'migrates metadata' do - expect { subject }.to change { job_artifacts.count }.by(1) - - expect(job_artifacts.exists?(job_id: job.id, file_type: described_class::METADATA_FILE_TYPE)).to be_truthy - end - end - - context 'when both archive and metadata existed' do - before do - job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: described_class::ARCHIVE_FILE_TYPE, size: 999, file: 'archive.zip') - job_artifacts.create!(project_id: project.id, job_id: job.id, file_type: described_class::METADATA_FILE_TYPE, size: 999, file: 'metadata.zip') - end - - it 'does not migrate' do - expect { subject }.not_to change { job_artifacts.count } - end - end - end - end - - context 'when the record exists outside of the range of a background migration' do - let(:range) { [job.id + 1, job.id + 1] } - - it 'does not migrate' do - expect { subject }.not_to change { job_artifacts.count } - end - end - end - - context 'when the job does not have legacy artifacts' do - let!(:job) { jobs.create!(commit_id: pipeline.id, project_id: project.id, status: :success) } - - it 'does not have the legacy artifacts in database' do - expect(jobs.count).to eq(1) - expect(jobs.pluck('artifacts_file, artifacts_file_store, artifacts_size, artifacts_expire_at')).to eq([[nil, nil, nil, nil]]) - expect(jobs.pluck('artifacts_metadata, artifacts_metadata_store')).to eq([[nil, nil]]) - end - - context 'when the record exists inside of the range of a background migration' do - let(:range) { [job.id, job.id] } - - it 'does not migrate' do - expect { subject }.not_to change { job_artifacts.count } - end - end - end - end -end diff --git a/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb b/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb index ab183d01357..fc957a7c425 100644 --- a/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb +++ b/spec/lib/gitlab/background_migration/migrate_u2f_webauthn_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' require 'webauthn/u2f_migrator' -RSpec.describe Gitlab::BackgroundMigration::MigrateU2fWebauthn, :migration, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::MigrateU2fWebauthn, :migration, schema: 20210301200959 do let(:users) { table(:users) } let(:user) { users.create!(email: 'email@email.com', name: 'foo', username: 'foo', projects_limit: 0) } diff --git a/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb index b34a57f51f1..79b5567f5b3 100644 --- a/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb +++ b/spec/lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::MoveContainerRegistryEnabledToProjectFeature, :migration, schema: 2021_02_26_120851 do +RSpec.describe Gitlab::BackgroundMigration::MoveContainerRegistryEnabledToProjectFeature, :migration, schema: 20210301200959 do let(:enabled) { 20 } let(:disabled) { 0 } diff --git a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb index 25006e663ab..68fe8f39f59 100644 --- a/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateFindingUuidForVulnerabilityFeedback, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::PopulateFindingUuidForVulnerabilityFeedback, schema: 20210301200959 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:users) { table(:users) } diff --git a/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb b/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb index a03a11489b5..b00eb185b34 100644 --- a/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_issue_email_participants_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateIssueEmailParticipants, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::PopulateIssueEmailParticipants, schema: 20210301200959 do let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } let!(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) } let!(:issue1) { table(:issues).create!(id: 1, project_id: project.id, service_desk_reply_to: "a@gitlab.com") } diff --git a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb index 4cdb56d3d3b..a54c840dd8e 100644 --- a/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb +++ b/spec/lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid_spec.rb @@ -2,82 +2,124 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, schema: 20181228175414 do +def create_background_migration_job(ids, status) + proper_status = case status + when :pending + Gitlab::Database::BackgroundMigrationJob.statuses['pending'] + when :succeeded + Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] + else + raise ArgumentError + end + + background_migration_jobs.create!( + class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', + arguments: Array(ids), + status: proper_status, + created_at: Time.now.utc + ) +end + +RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid, schema: 20211124132705 do + let(:background_migration_jobs) { table(:background_migration_jobs) } + let(:pending_jobs) { background_migration_jobs.where(status: Gitlab::Database::BackgroundMigrationJob.statuses['pending']) } + let(:succeeded_jobs) { background_migration_jobs.where(status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded']) } let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } let(:scanners) { table(:vulnerability_scanners) } let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } - let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } + let(:scanner2) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } let(:vulnerabilities) { table(:vulnerabilities) } - let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } + let(:vulnerability_findings) { table(:vulnerability_occurrences) } + let(:vulnerability_finding_pipelines) { table(:vulnerability_occurrence_pipelines) } + let(:vulnerability_finding_signatures) { table(:vulnerability_finding_signatures) } let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } - let(:vulnerability_identifier) do + let(:identifier_1) { 'identifier-1' } + let!(:vulnerability_identifier) do vulnerability_identifiers.create!( project_id: project.id, - external_type: 'uuid-v5', - external_id: 'uuid-v5', - fingerprint: Gitlab::Database::ShaAttribute.serialize('7e394d1b1eb461a7406d7b1e08f057a1cf11287a'), - name: 'Identifier for UUIDv5') + external_type: identifier_1, + external_id: identifier_1, + fingerprint: Gitlab::Database::ShaAttribute.serialize('ff9ef548a6e30a0462795d916f3f00d1e2b082ca'), + name: 'Identifier 1') end - let(:different_vulnerability_identifier) do + let(:identifier_2) { 'identifier-2' } + let!(:vulnerability_identfier2) do vulnerability_identifiers.create!( project_id: project.id, - external_type: 'uuid-v4', - external_id: 'uuid-v4', - fingerprint: Gitlab::Database::ShaAttribute.serialize('772da93d34a1ba010bcb5efa9fb6f8e01bafcc89'), - name: 'Identifier for UUIDv4') + external_type: identifier_2, + external_id: identifier_2, + fingerprint: Gitlab::Database::ShaAttribute.serialize('4299e8ddd819f9bde9cfacf45716724c17b5ddf7'), + name: 'Identifier 2') end - let!(:vulnerability_for_uuidv4) do - create_vulnerability!( - project_id: project.id, - author_id: user.id - ) - end - - let!(:vulnerability_for_uuidv5) do - create_vulnerability!( + let(:identifier_3) { 'identifier-3' } + let!(:vulnerability_identifier3) do + vulnerability_identifiers.create!( project_id: project.id, - author_id: user.id - ) + external_type: identifier_3, + external_id: identifier_3, + fingerprint: Gitlab::Database::ShaAttribute.serialize('8e91632f9c6671e951834a723ee221c44cc0d844'), + name: 'Identifier 3') end - let(:known_uuid_v5) { "77211ed6-7dff-5f6b-8c9a-da89ad0a9b60" } let(:known_uuid_v4) { "b3cc2518-5446-4dea-871c-89d5e999c1ac" } - let(:desired_uuid_v5) { "3ca8ad45-6344-508b-b5e3-306a3bd6c6ba" } + let(:known_uuid_v5) { "05377088-dc26-5161-920e-52a7159fdaa1" } + let(:desired_uuid_v5) { "f3e9a23f-9181-54bf-a5ab-c5bc7a9b881a" } - subject { described_class.new.perform(finding.id, finding.id) } + subject { described_class.new.perform(start_id, end_id) } + + context 'when the migration is disabled by the feature flag' do + let(:start_id) { 1 } + let(:end_id) { 1001 } + + before do + stub_feature_flags(migrate_vulnerability_finding_uuids: false) + end + + it 'logs the info message and does not run the migration' do + expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance| + expect(instance).to receive(:info).once.with(message: 'Migration is disabled by the feature flag', + migrator: 'RecalculateVulnerabilitiesOccurrencesUuid', + start_id: start_id, + end_id: end_id) + end + + subject + end + end context "when finding has a UUIDv4" do before do @uuid_v4 = create_finding!( - vulnerability_id: vulnerability_for_uuidv4.id, + vulnerability_id: nil, project_id: project.id, - scanner_id: different_scanner.id, - primary_identifier_id: different_vulnerability_identifier.id, + scanner_id: scanner2.id, + primary_identifier_id: vulnerability_identfier2.id, report_type: 0, # "sast" location_fingerprint: Gitlab::Database::ShaAttribute.serialize("fa18f432f1d56675f4098d318739c3cd5b14eb3e"), uuid: known_uuid_v4 ) end - let(:finding) { @uuid_v4 } + let(:start_id) { @uuid_v4.id } + let(:end_id) { @uuid_v4.id } it "replaces it with UUIDv5" do - expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v4]) + expect(vulnerability_findings.pluck(:uuid)).to match_array([known_uuid_v4]) subject - expect(vulnerabilities_findings.pluck(:uuid)).to eq([desired_uuid_v5]) + expect(vulnerability_findings.pluck(:uuid)).to match_array([desired_uuid_v5]) end it 'logs recalculation' do expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance| - expect(instance).to receive(:info).once + expect(instance).to receive(:info).twice end subject @@ -87,7 +129,7 @@ RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrence context "when finding has a UUIDv5" do before do @uuid_v5 = create_finding!( - vulnerability_id: vulnerability_for_uuidv5.id, + vulnerability_id: nil, project_id: project.id, scanner_id: scanner.id, primary_identifier_id: vulnerability_identifier.id, @@ -97,40 +139,340 @@ RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrence ) end - let(:finding) { @uuid_v5 } + let(:start_id) { @uuid_v5.id } + let(:end_id) { @uuid_v5.id } it "stays the same" do - expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v5]) + expect(vulnerability_findings.pluck(:uuid)).to match_array([known_uuid_v5]) subject - expect(vulnerabilities_findings.pluck(:uuid)).to eq([known_uuid_v5]) + expect(vulnerability_findings.pluck(:uuid)).to match_array([known_uuid_v5]) + end + end + + context 'if a duplicate UUID would be generated' do # rubocop: disable RSpec/MultipleMemoizedHelpers + let(:v1) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:finding_with_incorrect_uuid) do + create_finding!( + vulnerability_id: v1.id, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: 'bd95c085-71aa-51d7-9bb6-08ae669c262e' + ) + end + + let(:v2) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:finding_with_correct_uuid) do + create_finding!( + vulnerability_id: v2.id, + project_id: project.id, + primary_identifier_id: vulnerability_identifier.id, + scanner_id: scanner2.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: '91984483-5efe-5215-b471-d524ac5792b1' + ) + end + + let(:v3) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:finding_with_incorrect_uuid2) do + create_finding!( + vulnerability_id: v3.id, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identfier2.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: '00000000-1111-2222-3333-444444444444' + ) + end + + let(:v4) do + create_vulnerability!( + project_id: project.id, + author_id: user.id + ) + end + + let!(:finding_with_correct_uuid2) do + create_finding!( + vulnerability_id: v4.id, + project_id: project.id, + scanner_id: scanner2.id, + primary_identifier_id: vulnerability_identfier2.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: '1edd751e-ef9a-5391-94db-a832c8635bfc' + ) + end + + let!(:finding_with_incorrect_uuid3) do + create_finding!( + vulnerability_id: nil, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier3.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: '22222222-3333-4444-5555-666666666666' + ) + end + + let!(:duplicate_not_in_the_same_batch) do + create_finding!( + id: 99999, + vulnerability_id: nil, + project_id: project.id, + scanner_id: scanner2.id, + primary_identifier_id: vulnerability_identifier3.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: '4564f9d5-3c6b-5cc3-af8c-7c25285362a7' + ) + end + + let(:start_id) { finding_with_incorrect_uuid.id } + let(:end_id) { finding_with_incorrect_uuid3.id } + + before do + 4.times do + create_finding_pipeline!(project_id: project.id, finding_id: finding_with_incorrect_uuid.id) + create_finding_pipeline!(project_id: project.id, finding_id: finding_with_correct_uuid.id) + create_finding_pipeline!(project_id: project.id, finding_id: finding_with_incorrect_uuid2.id) + create_finding_pipeline!(project_id: project.id, finding_id: finding_with_correct_uuid2.id) + end + end + + it 'drops duplicates and related records', :aggregate_failures do + expect(vulnerability_findings.pluck(:id)).to match_array([ + finding_with_correct_uuid.id, finding_with_incorrect_uuid.id, finding_with_correct_uuid2.id, finding_with_incorrect_uuid2.id, finding_with_incorrect_uuid3.id, duplicate_not_in_the_same_batch.id + ]) + + expect { subject }.to change(vulnerability_finding_pipelines, :count).from(16).to(8) + .and change(vulnerability_findings, :count).from(6).to(3) + .and change(vulnerabilities, :count).from(4).to(2) + + expect(vulnerability_findings.pluck(:id)).to match_array([finding_with_incorrect_uuid.id, finding_with_incorrect_uuid2.id, finding_with_incorrect_uuid3.id]) + end + + context 'if there are conflicting UUID values within the batch' do # rubocop: disable RSpec/MultipleMemoizedHelpers + let(:end_id) { finding_with_broken_data_integrity.id } + let(:vulnerability_5) { create_vulnerability!(project_id: project.id, author_id: user.id) } + let(:different_project) { table(:projects).create!(namespace_id: namespace.id) } + let!(:identifier_with_broken_data_integrity) do + vulnerability_identifiers.create!( + project_id: different_project.id, + external_type: identifier_2, + external_id: identifier_2, + fingerprint: Gitlab::Database::ShaAttribute.serialize('4299e8ddd819f9bde9cfacf45716724c17b5ddf7'), + name: 'Identifier 2') + end + + let(:finding_with_broken_data_integrity) do + create_finding!( + vulnerability_id: vulnerability_5, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: identifier_with_broken_data_integrity.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: SecureRandom.uuid + ) + end + + it 'deletes the conflicting record' do + expect { subject }.to change { vulnerability_findings.find_by_id(finding_with_broken_data_integrity.id) }.to(nil) + end + end + + context 'if a conflicting UUID is found during the migration' do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:finding_class) { Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid::VulnerabilitiesFinding } + let(:uuid) { '4564f9d5-3c6b-5cc3-af8c-7c25285362a7' } + + before do + exception = ActiveRecord::RecordNotUnique.new("(uuid)=(#{uuid})") + + call_count = 0 + allow(::Gitlab::Database::BulkUpdate).to receive(:execute) do + call_count += 1 + call_count.eql?(1) ? raise(exception) : {} + end + + allow(finding_class).to receive(:find_by).with(uuid: uuid).and_return(duplicate_not_in_the_same_batch) + end + + it 'retries the recalculation' do + subject + + expect(Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrencesUuid::VulnerabilitiesFinding).to have_received(:find_by).with(uuid: uuid).once + end + + it 'logs the conflict' do + expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance| + expect(instance).to receive(:info).exactly(6).times + end + + subject + end + + it 'marks the job as done' do + create_background_migration_job([start_id, end_id], :pending) + + subject + + expect(pending_jobs.count).to eq(0) + expect(succeeded_jobs.count).to eq(1) + end + end + + it 'logs an exception if a different uniquness problem was found' do + exception = ActiveRecord::RecordNotUnique.new("Totally not an UUID uniqueness problem") + allow(::Gitlab::Database::BulkUpdate).to receive(:execute).and_raise(exception) + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception) + + subject + + expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_exception).with(exception).once + end + + it 'logs a duplicate found message' do + expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance| + expect(instance).to receive(:info).exactly(3).times + end + + subject + end + end + + context 'when finding has a signature' do + before do + @f1 = create_finding!( + vulnerability_id: nil, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: 'd15d774d-e4b1-5a1b-929b-19f2a53e35ec' + ) + + vulnerability_finding_signatures.create!( + finding_id: @f1.id, + algorithm_type: 2, # location + signature_sha: Gitlab::Database::ShaAttribute.serialize('57d4e05205f6462a73f039a5b2751aa1ab344e6e') # sha1('youshouldusethis') + ) + + vulnerability_finding_signatures.create!( + finding_id: @f1.id, + algorithm_type: 1, # hash + signature_sha: Gitlab::Database::ShaAttribute.serialize('c554d8d8df1a7a14319eafdaae24af421bf5b587') # sha1('andnotthis') + ) + + @f2 = create_finding!( + vulnerability_id: nil, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identfier2.id, + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('ca41a2544e941a007a73a666cb0592b255316ab8'), # sha1('youshouldntusethis') + uuid: '4be029b5-75e5-5ac0-81a2-50ab41726135' + ) + + vulnerability_finding_signatures.create!( + finding_id: @f2.id, + algorithm_type: 2, # location + signature_sha: Gitlab::Database::ShaAttribute.serialize('57d4e05205f6462a73f039a5b2751aa1ab344e6e') # sha1('youshouldusethis') + ) + + vulnerability_finding_signatures.create!( + finding_id: @f2.id, + algorithm_type: 1, # hash + signature_sha: Gitlab::Database::ShaAttribute.serialize('c554d8d8df1a7a14319eafdaae24af421bf5b587') # sha1('andnotthis') + ) + end + + let(:start_id) { @f1.id } + let(:end_id) { @f2.id } + + let(:uuids_before) { [@f1.uuid, @f2.uuid] } + let(:uuids_after) { %w[d3b60ddd-d312-5606-b4d3-ad058eebeacb 349d9bec-c677-5530-a8ac-5e58889c3b1a] } + + it 'is recalculated using signature' do + expect(vulnerability_findings.pluck(:uuid)).to match_array(uuids_before) + + subject + + expect(vulnerability_findings.pluck(:uuid)).to match_array(uuids_after) + end + end + + context 'if all records are removed before the job ran' do + let(:start_id) { 1 } + let(:end_id) { 9 } + + before do + create_background_migration_job([start_id, end_id], :pending) + end + + it 'does not error out' do + expect { subject }.not_to raise_error + end + + it 'marks the job as done' do + subject + + expect(pending_jobs.count).to eq(0) + expect(succeeded_jobs.count).to eq(1) end end context 'when recalculation fails' do before do @uuid_v4 = create_finding!( - vulnerability_id: vulnerability_for_uuidv4.id, + vulnerability_id: nil, project_id: project.id, - scanner_id: different_scanner.id, - primary_identifier_id: different_vulnerability_identifier.id, + scanner_id: scanner2.id, + primary_identifier_id: vulnerability_identfier2.id, report_type: 0, # "sast" location_fingerprint: Gitlab::Database::ShaAttribute.serialize("fa18f432f1d56675f4098d318739c3cd5b14eb3e"), uuid: known_uuid_v4 ) - allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception) allow(::Gitlab::Database::BulkUpdate).to receive(:execute).and_raise(expected_error) end - let(:finding) { @uuid_v4 } + let(:start_id) { @uuid_v4.id } + let(:end_id) { @uuid_v4.id } let(:expected_error) { RuntimeError.new } it 'captures the errors and does not crash entirely' do expect { subject }.not_to raise_error - expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_for_dev_exception).with(expected_error).once + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_exception) + expect(Gitlab::ErrorTracking).to have_received(:track_and_raise_exception).with(expected_error).once end end @@ -149,25 +491,28 @@ RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrence # rubocop:disable Metrics/ParameterLists def create_finding!( + id: nil, vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, name: "test", severity: 7, confidence: 7, report_type: 0, project_fingerprint: '123qweasdzxc', location_fingerprint: 'test', metadata_version: 'test', raw_metadata: 'test', uuid: 'test') - vulnerabilities_findings.create!( - vulnerability_id: vulnerability_id, - project_id: project_id, - name: name, - severity: severity, - confidence: confidence, - report_type: report_type, - project_fingerprint: project_fingerprint, - scanner_id: scanner.id, - primary_identifier_id: vulnerability_identifier.id, - location_fingerprint: location_fingerprint, - metadata_version: metadata_version, - raw_metadata: raw_metadata, - uuid: uuid - ) + vulnerability_findings.create!({ + id: id, + vulnerability_id: vulnerability_id, + project_id: project_id, + name: name, + severity: severity, + confidence: confidence, + report_type: report_type, + project_fingerprint: project_fingerprint, + scanner_id: scanner_id, + primary_identifier_id: primary_identifier_id, + location_fingerprint: location_fingerprint, + metadata_version: metadata_version, + raw_metadata: raw_metadata, + uuid: uuid + }.compact + ) end # rubocop:enable Metrics/ParameterLists @@ -181,4 +526,9 @@ RSpec.describe Gitlab::BackgroundMigration::RecalculateVulnerabilitiesOccurrence confirmed_at: confirmed_at ) end + + def create_finding_pipeline!(project_id:, finding_id:) + pipeline = table(:ci_pipelines).create!(project_id: project_id) + vulnerability_finding_pipelines.create!(pipeline_id: pipeline.id, occurrence_id: finding_id) + end end diff --git a/spec/lib/gitlab/background_migration/remove_duplicate_services_spec.rb b/spec/lib/gitlab/background_migration/remove_duplicate_services_spec.rb deleted file mode 100644 index afcdaaf1cb8..00000000000 --- a/spec/lib/gitlab/background_migration/remove_duplicate_services_spec.rb +++ /dev/null @@ -1,121 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateServices, :migration, schema: 20181228175414 do - let_it_be(:users) { table(:users) } - let_it_be(:namespaces) { table(:namespaces) } - let_it_be(:projects) { table(:projects) } - let_it_be(:services) { table(:services) } - - let_it_be(:alerts_service_data) { table(:alerts_service_data) } - let_it_be(:chat_names) { table(:chat_names) } - let_it_be(:issue_tracker_data) { table(:issue_tracker_data) } - let_it_be(:jira_tracker_data) { table(:jira_tracker_data) } - let_it_be(:open_project_tracker_data) { table(:open_project_tracker_data) } - let_it_be(:slack_integrations) { table(:slack_integrations) } - let_it_be(:web_hooks) { table(:web_hooks) } - - let_it_be(:data_tables) do - [alerts_service_data, chat_names, issue_tracker_data, jira_tracker_data, open_project_tracker_data, slack_integrations, web_hooks] - end - - let!(:user) { users.create!(id: 1, projects_limit: 100) } - let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') } - - # project without duplicate services - let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id) } - let!(:service1) { services.create!(id: 1, project_id: project1.id, type: 'AsanaService') } - let!(:service2) { services.create!(id: 2, project_id: project1.id, type: 'JiraService') } - let!(:service3) { services.create!(id: 3, project_id: project1.id, type: 'SlackService') } - - # project with duplicate services - let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id) } - let!(:service4) { services.create!(id: 4, project_id: project2.id, type: 'AsanaService') } - let!(:service5) { services.create!(id: 5, project_id: project2.id, type: 'JiraService') } - let!(:service6) { services.create!(id: 6, project_id: project2.id, type: 'JiraService') } - let!(:service7) { services.create!(id: 7, project_id: project2.id, type: 'SlackService') } - let!(:service8) { services.create!(id: 8, project_id: project2.id, type: 'SlackService') } - let!(:service9) { services.create!(id: 9, project_id: project2.id, type: 'SlackService') } - - # project with duplicate services and dependant records - let!(:project3) { projects.create!(id: 3, namespace_id: namespace.id) } - let!(:service10) { services.create!(id: 10, project_id: project3.id, type: 'AlertsService') } - let!(:service11) { services.create!(id: 11, project_id: project3.id, type: 'AlertsService') } - let!(:service12) { services.create!(id: 12, project_id: project3.id, type: 'SlashCommandsService') } - let!(:service13) { services.create!(id: 13, project_id: project3.id, type: 'SlashCommandsService') } - let!(:service14) { services.create!(id: 14, project_id: project3.id, type: 'IssueTrackerService') } - let!(:service15) { services.create!(id: 15, project_id: project3.id, type: 'IssueTrackerService') } - let!(:service16) { services.create!(id: 16, project_id: project3.id, type: 'JiraService') } - let!(:service17) { services.create!(id: 17, project_id: project3.id, type: 'JiraService') } - let!(:service18) { services.create!(id: 18, project_id: project3.id, type: 'OpenProjectService') } - let!(:service19) { services.create!(id: 19, project_id: project3.id, type: 'OpenProjectService') } - let!(:service20) { services.create!(id: 20, project_id: project3.id, type: 'SlackService') } - let!(:service21) { services.create!(id: 21, project_id: project3.id, type: 'SlackService') } - let!(:dependant_records) do - alerts_service_data.create!(id: 1, service_id: service10.id) - alerts_service_data.create!(id: 2, service_id: service11.id) - chat_names.create!(id: 1, service_id: service12.id, user_id: user.id, team_id: 'team1', chat_id: 'chat1') - chat_names.create!(id: 2, service_id: service13.id, user_id: user.id, team_id: 'team2', chat_id: 'chat2') - issue_tracker_data.create!(id: 1, service_id: service14.id) - issue_tracker_data.create!(id: 2, service_id: service15.id) - jira_tracker_data.create!(id: 1, service_id: service16.id) - jira_tracker_data.create!(id: 2, service_id: service17.id) - open_project_tracker_data.create!(id: 1, service_id: service18.id) - open_project_tracker_data.create!(id: 2, service_id: service19.id) - slack_integrations.create!(id: 1, service_id: service20.id, user_id: user.id, team_id: 'team1', team_name: 'team1', alias: 'alias1') - slack_integrations.create!(id: 2, service_id: service21.id, user_id: user.id, team_id: 'team2', team_name: 'team2', alias: 'alias2') - web_hooks.create!(id: 1, service_id: service20.id) - web_hooks.create!(id: 2, service_id: service21.id) - end - - # project without services - let!(:project4) { projects.create!(id: 4, namespace_id: namespace.id) } - - it 'removes duplicate services and dependant records' do - # Determine which services we expect to keep - expected_services = projects.pluck(:id).each_with_object({}) do |project_id, map| - project_services = services.where(project_id: project_id) - types = project_services.distinct.pluck(:type) - - map[project_id] = types.map { |type| project_services.where(type: type).take!.id } - end - - expect do - subject.perform(project2.id, project3.id) - end.to change { services.count }.from(21).to(12) - - services1 = services.where(project_id: project1.id) - expect(services1.count).to be(3) - expect(services1.pluck(:type)).to contain_exactly('AsanaService', 'JiraService', 'SlackService') - expect(services1.pluck(:id)).to contain_exactly(*expected_services[project1.id]) - - services2 = services.where(project_id: project2.id) - expect(services2.count).to be(3) - expect(services2.pluck(:type)).to contain_exactly('AsanaService', 'JiraService', 'SlackService') - expect(services2.pluck(:id)).to contain_exactly(*expected_services[project2.id]) - - services3 = services.where(project_id: project3.id) - expect(services3.count).to be(6) - expect(services3.pluck(:type)).to contain_exactly('AlertsService', 'SlashCommandsService', 'IssueTrackerService', 'JiraService', 'OpenProjectService', 'SlackService') - expect(services3.pluck(:id)).to contain_exactly(*expected_services[project3.id]) - - kept_services = expected_services.values.flatten - data_tables.each do |table| - expect(table.count).to be(1) - expect(kept_services).to include(table.pluck(:service_id).first) - end - end - - it 'does not delete services without duplicates' do - expect do - subject.perform(project1.id, project4.id) - end.not_to change { services.count } - end - - it 'only deletes duplicate services for the current batch' do - expect do - subject.perform(project2.id) - end.to change { services.count }.by(-3) - end -end diff --git a/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb b/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb index fadee64886f..ccf96e036ae 100644 --- a/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb +++ b/spec/lib/gitlab/background_migration/remove_vulnerability_finding_links_spec.rb @@ -41,8 +41,8 @@ RSpec.describe Gitlab::BackgroundMigration::RemoveVulnerabilityFindingLinks, :mi # vulnerability finding links let!(:links) do { - findings.first => Array.new(5) { |id| finding_links.create!(vulnerability_occurrence_id: findings.first.id, name: "Link Name 1", url: "link_url1.example") }, - findings.second => Array.new(5) { |id| finding_links.create!(vulnerability_occurrence_id: findings.second.id, name: "Link Name 2", url: "link_url2.example") } + findings.first => Array.new(5) { |id| finding_links.create!(vulnerability_occurrence_id: findings.first.id, name: "Link Name 1", url: "link_url1_#{id}.example") }, + findings.second => Array.new(5) { |id| finding_links.create!(vulnerability_occurrence_id: findings.second.id, name: "Link Name 2", url: "link_url2_#{id}.example") } } end diff --git a/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb b/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb index 5c197526a55..17fe25c7f71 100644 --- a/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb +++ b/spec/lib/gitlab/background_migration/wrongfully_confirmed_email_unconfirmer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::WrongfullyConfirmedEmailUnconfirmer, schema: 20181228175414 do +RSpec.describe Gitlab::BackgroundMigration::WrongfullyConfirmedEmailUnconfirmer, schema: 20210301200959 do let(:users) { table(:users) } let(:emails) { table(:emails) } let(:user_synced_attributes_metadata) { table(:user_synced_attributes_metadata) } diff --git a/spec/lib/gitlab/checks/changes_access_spec.rb b/spec/lib/gitlab/checks/changes_access_spec.rb index 633c4baa931..1cb4edd7337 100644 --- a/spec/lib/gitlab/checks/changes_access_spec.rb +++ b/spec/lib/gitlab/checks/changes_access_spec.rb @@ -44,16 +44,30 @@ RSpec.describe Gitlab::Checks::ChangesAccess do it 'calls #new_commits' do expect(project.repository).to receive(:new_commits).and_call_original - expect(subject.commits).to eq([]) + expect(subject.commits).to match_array([]) end context 'when changes contain empty revisions' do - let(:changes) { [{ newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] } let(:expected_commit) { instance_double(Commit) } - it 'returns only commits with non empty revisions' do - expect(project.repository).to receive(:new_commits).with([newrev], { allow_quarantine: true }) { [expected_commit] } - expect(subject.commits).to eq([expected_commit]) + shared_examples 'returns only commits with non empty revisions' do + specify do + expect(project.repository).to receive(:new_commits).with([newrev], { allow_quarantine: allow_quarantine }) { [expected_commit] } + expect(subject.commits).to match_array([expected_commit]) + end + end + + it_behaves_like 'returns only commits with non empty revisions' do + let(:changes) { [{ oldrev: oldrev, newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] } + let(:allow_quarantine) { true } + end + + context 'without oldrev' do + it_behaves_like 'returns only commits with non empty revisions' do + let(:changes) { [{ newrev: newrev }, { newrev: '' }, { newrev: Gitlab::Git::BLANK_SHA }] } + # The quarantine directory should not be used because we're lacking oldrev. + let(:allow_quarantine) { false } + end end end end @@ -61,12 +75,13 @@ RSpec.describe Gitlab::Checks::ChangesAccess do describe '#commits_for' do let(:new_commits) { [] } let(:expected_commits) { [] } + let(:oldrev) { Gitlab::Git::BLANK_SHA } shared_examples 'a listing of new commits' do it 'returns expected commits' do expect(subject).to receive(:commits).and_return(new_commits) - expect(subject.commits_for(newrev)).to eq(expected_commits) + expect(subject.commits_for(oldrev, newrev)).to eq(expected_commits) end end @@ -172,6 +187,31 @@ RSpec.describe Gitlab::Checks::ChangesAccess do it_behaves_like 'a listing of new commits' end + + context 'with over-push' do + let(:newrev) { '1' } + let(:oldrev) { '3' } + + # `#new_commits` returns too many commits, where some commits are not + # part of the current change. + let(:new_commits) do + [ + create_commit('1', %w[2]), + create_commit('2', %w[3]), + create_commit('3', %w[4]), + create_commit('4', %w[]) + ] + end + + let(:expected_commits) do + [ + create_commit('1', %w[2]), + create_commit('2', %w[3]) + ] + end + + it_behaves_like 'a listing of new commits' + end end describe '#single_change_accesses' do @@ -180,10 +220,10 @@ RSpec.describe Gitlab::Checks::ChangesAccess do shared_examples '#single_change_access' do before do - commits_for.each do |id, commits| + commits_for.each do |oldrev, newrev, commits| expect(subject) .to receive(:commits_for) - .with(id) + .with(oldrev, newrev) .and_return(commits) end end @@ -205,7 +245,12 @@ RSpec.describe Gitlab::Checks::ChangesAccess do end context 'with a single change and no new commits' do - let(:commits_for) { { 'new' => [] } } + let(:commits_for) do + [ + ['old', 'new', []] + ] + end + let(:changes) do [ { oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch' } @@ -222,7 +267,12 @@ RSpec.describe Gitlab::Checks::ChangesAccess do end context 'with a single change and new commits' do - let(:commits_for) { { 'new' => [create_commit('new', [])] } } + let(:commits_for) do + [ + ['old', 'new', [create_commit('new', [])]] + ] + end + let(:changes) do [ { oldrev: 'old', newrev: 'new', ref: 'refs/heads/branch' } @@ -240,11 +290,11 @@ RSpec.describe Gitlab::Checks::ChangesAccess do context 'with multiple changes' do let(:commits_for) do - { - 'a' => [create_commit('a', [])], - 'c' => [create_commit('c', [])], - 'd' => [] - } + [ + [nil, 'a', [create_commit('a', [])]], + ['a', 'c', [create_commit('c', [])]], + [nil, 'd', []] + ] end let(:changes) do diff --git a/spec/lib/gitlab/ci/build/status/reason_spec.rb b/spec/lib/gitlab/ci/build/status/reason_spec.rb new file mode 100644 index 00000000000..64f35c3f464 --- /dev/null +++ b/spec/lib/gitlab/ci/build/status/reason_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Build::Status::Reason do + let(:build) { double('build') } + + describe '.fabricate' do + context 'when failure symbol reason is being passed' do + it 'correctly fabricates a status reason object' do + reason = described_class.fabricate(build, :script_failure) + + expect(reason.failure_reason_enum).to eq 1 + end + end + + context 'when another status reason object is being passed' do + it 'correctly fabricates a status reason object' do + reason = described_class.fabricate(build, :script_failure) + + new_reason = described_class.fabricate(build, reason) + + expect(new_reason.failure_reason_enum).to eq 1 + end + end + end + + describe '#failure_reason_enum' do + it 'exposes a failure reason enum' do + reason = described_class.fabricate(build, :script_failure) + + enum = ::CommitStatus.failure_reasons[:script_failure] + + expect(reason.failure_reason_enum).to eq enum + end + end + + describe '#force_allow_failure?' do + context 'when build is not allowed to fail' do + context 'when build is allowed to fail with a given exit code' do + it 'returns true' do + reason = described_class.new(build, :script_failure, 11) + + allow(build).to receive(:allow_failure?).and_return(false) + allow(build).to receive(:allowed_to_fail_with_code?) + .with(11) + .and_return(true) + + expect(reason.force_allow_failure?).to be true + end + end + + context 'when build is not allowed to fail regardless of an exit code' do + it 'returns false' do + reason = described_class.new(build, :script_failure, 11) + + allow(build).to receive(:allow_failure?).and_return(false) + allow(build).to receive(:allowed_to_fail_with_code?) + .with(11) + .and_return(false) + + expect(reason.force_allow_failure?).to be false + end + end + + context 'when an exit code is not specified' do + it 'returns false' do + reason = described_class.new(build, :script_failure) + + expect(reason.force_allow_failure?).to be false + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index d862fbf5b78..749d1386ed9 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Root do - let(:root) { described_class.new(hash) } + let(:user) {} + let(:project) {} + let(:root) { described_class.new(hash, user: user, project: project) } describe '.nodes' do it 'returns a hash' do @@ -53,6 +55,37 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do } end + context 'when deprecated types keyword is defined' do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + let(:hash) do + { types: %w(test deploy), + rspec: { script: 'rspec' } } + end + + before do + root.compose! + end + + it 'returns array of types as stages with a warning' do + expect(root.stages_value).to eq %w[test deploy] + expect(root.warnings).to match_array(["root `types` is deprecated in 9.0 and will be removed in 15.0."]) + end + + it 'logs usage of types keyword' do + expect(Gitlab::AppJsonLogger).to( + receive(:info) + .with(event: 'ci_used_deprecated_keyword', + entry: root[:stages].key.to_s, + user_id: user.id, + project_id: project.id) + ) + + root.compose! + end + end + describe '#compose!' do before do root.compose! @@ -108,17 +141,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do expect(root.stages_value).to eq %w[build pages release] end end - - context 'when deprecated types key defined' do - let(:hash) do - { types: %w(test deploy), - rspec: { script: 'rspec' } } - end - - it 'returns array of types as stages' do - expect(root.stages_value).to eq %w[test deploy] - end - end end describe '#jobs_value' do diff --git a/spec/lib/gitlab/ci/jwt_v2_spec.rb b/spec/lib/gitlab/ci/jwt_v2_spec.rb new file mode 100644 index 00000000000..33aaa145a39 --- /dev/null +++ b/spec/lib/gitlab/ci/jwt_v2_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::JwtV2 do + let(:namespace) { build_stubbed(:namespace) } + let(:project) { build_stubbed(:project, namespace: namespace) } + let(:user) { build_stubbed(:user) } + let(:pipeline) { build_stubbed(:ci_pipeline, ref: 'auto-deploy-2020-03-19') } + let(:build) do + build_stubbed( + :ci_build, + project: project, + user: user, + pipeline: pipeline + ) + end + + subject(:ci_job_jwt_v2) { described_class.new(build, ttl: 30) } + + it { is_expected.to be_a Gitlab::Ci::Jwt } + + describe '#payload' do + subject(:payload) { ci_job_jwt_v2.payload } + + it 'has correct values for the standard JWT attributes' do + aggregate_failures do + expect(payload[:iss]).to eq(Settings.gitlab.base_url) + expect(payload[:aud]).to eq(Settings.gitlab.base_url) + expect(payload[:sub]).to eq("project_path:#{project.full_path}:ref_type:branch:ref:#{pipeline.source_ref}") + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb index 28bc685286f..0a592395c3a 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb @@ -38,20 +38,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::CreateDeployments do expect(job.deployment.environment).to eq(job.persisted_environment) end - context 'when creation failure occures' do - before do - allow_next_instance_of(Deployment) do |deployment| - allow(deployment).to receive(:save!) { raise ActiveRecord::RecordInvalid } - end - end - - it 'trackes the exception' do - expect { subject }.to raise_error(described_class::DeploymentCreationError) - - expect(Deployment.count).to eq(0) - end - end - context 'when the corresponding environment does not exist' do let!(:environment) { } diff --git a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb index 4206483b228..1d020d3ea79 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/create_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Create do let_it_be(:user) { create(:user) } let(:pipeline) do - build(:ci_empty_pipeline, project: project, ref: 'master') + build(:ci_empty_pipeline, project: project, ref: 'master', user: user) end let(:command) do @@ -59,7 +59,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Create do context 'tags persistence' do let(:stage) do - build(:ci_stage_entity, pipeline: pipeline) + build(:ci_stage_entity, pipeline: pipeline, project: project) end let(:job) do @@ -79,12 +79,11 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Create do it 'extracts an empty tag list' do expect(CommitStatus) .to receive(:bulk_insert_tags!) - .with(stage.statuses, {}) + .with([job]) .and_call_original step.perform! - expect(job.instance_variable_defined?(:@tag_list)).to be_falsey expect(job).to be_persisted expect(job.tag_list).to eq([]) end @@ -98,14 +97,13 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Create do it 'bulk inserts tags' do expect(CommitStatus) .to receive(:bulk_insert_tags!) - .with(stage.statuses, { job.name => %w[tag1 tag2] }) + .with([job]) .and_call_original step.perform! - expect(job.instance_variable_defined?(:@tag_list)).to be_falsey expect(job).to be_persisted - expect(job.tag_list).to match_array(%w[tag1 tag2]) + expect(job.reload.tag_list).to match_array(%w[tag1 tag2]) end end @@ -120,7 +118,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Create do step.perform! - expect(job.instance_variable_defined?(:@tag_list)).to be_truthy expect(job).to be_persisted expect(job.reload.tag_list).to match_array(%w[tag1 tag2]) end diff --git a/spec/lib/gitlab/ci/pipeline/logger_spec.rb b/spec/lib/gitlab/ci/pipeline/logger_spec.rb index 0b44e35dec1..a488bc184f8 100644 --- a/spec/lib/gitlab/ci/pipeline/logger_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/logger_spec.rb @@ -41,6 +41,90 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Logger do end end + describe '#instrument_with_sql', :request_store do + subject(:instrument_with_sql) do + logger.instrument_with_sql(:expensive_operation, &operation) + end + + def loggable_data(count:, db_count: nil) + keys = %w[ + expensive_operation_duration_s + expensive_operation_db_count + expensive_operation_db_primary_count + expensive_operation_db_primary_duration_s + expensive_operation_db_main_count + expensive_operation_db_main_duration_s + ] + + data = keys.each.with_object({}) do |key, accumulator| + accumulator[key] = { + 'count' => count, + 'avg' => a_kind_of(Numeric), + 'max' => a_kind_of(Numeric), + 'min' => a_kind_of(Numeric) + } + end + + if db_count + data['expensive_operation_db_count']['max'] = db_count + data['expensive_operation_db_count']['min'] = db_count + data['expensive_operation_db_count']['avg'] = db_count + end + + data + end + + context 'with a single query' do + let(:operation) { -> { Project.count } } + + it { is_expected.to eq(operation.call) } + + it 'includes SQL metrics' do + instrument_with_sql + + expect(logger.observations_hash) + .to match(a_hash_including(loggable_data(count: 1, db_count: 1))) + end + end + + context 'with multiple queries' do + let(:operation) { -> { Ci::Build.count + Ci::Bridge.count } } + + it { is_expected.to eq(operation.call) } + + it 'includes SQL metrics' do + instrument_with_sql + + expect(logger.observations_hash) + .to match(a_hash_including(loggable_data(count: 1, db_count: 2))) + end + end + + context 'with multiple observations' do + let(:operation) { -> { Ci::Build.count + Ci::Bridge.count } } + + it 'includes SQL metrics' do + 2.times { logger.instrument_with_sql(:expensive_operation, &operation) } + + expect(logger.observations_hash) + .to match(a_hash_including(loggable_data(count: 2, db_count: 2))) + end + end + + context 'when there are not SQL operations' do + let(:operation) { -> { 123 } } + + it { is_expected.to eq(operation.call) } + + it 'does not include SQL metrics' do + instrument_with_sql + + expect(logger.observations_hash.keys) + .to match_array(['expensive_operation_duration_s']) + end + end + end + describe '#observe' do it 'records durations of observed operations' do loggable_data = { diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 68806fbf287..2f9fcd7caac 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do let(:pipeline) { build(:ci_empty_pipeline, project: project, sha: head_sha) } let(:root_variables) { [] } - let(:seed_context) { double(pipeline: pipeline, root_variables: root_variables) } + let(:seed_context) { Gitlab::Ci::Pipeline::Seed::Context.new(pipeline, root_variables: root_variables) } let(:attributes) { { name: 'rspec', ref: 'master', scheduling_type: :stage, when: 'on_success' } } let(:previous_stages) { [] } let(:current_stage) { double(seeds_names: [attributes[:name]]) } diff --git a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb index 5d8a9358e10..a76b4874eca 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/pipeline_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Pipeline do let_it_be(:project) { create(:project, :repository) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let(:seed_context) { double(pipeline: pipeline, root_variables: []) } + let(:seed_context) { Gitlab::Ci::Pipeline::Seed::Context.new(pipeline, root_variables: []) } let(:stages_attributes) do [ diff --git a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb index 5b04d2abd88..a632b5dedcf 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/stage_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Stage do let(:project) { create(:project, :repository) } let(:pipeline) { create(:ci_empty_pipeline, project: project) } let(:previous_stages) { [] } - let(:seed_context) { double(pipeline: pipeline, root_variables: []) } + let(:seed_context) { Gitlab::Ci::Pipeline::Seed::Context.new(pipeline, root_variables: []) } let(:attributes) do { name: 'test', diff --git a/spec/lib/gitlab/ci/status/build/waiting_for_approval_spec.rb b/spec/lib/gitlab/ci/status/build/waiting_for_approval_spec.rb new file mode 100644 index 00000000000..b703a8a47ac --- /dev/null +++ b/spec/lib/gitlab/ci/status/build/waiting_for_approval_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Status::Build::WaitingForApproval do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + subject { described_class.new(Gitlab::Ci::Status::Core.new(build, user)) } + + describe '#illustration' do + let(:build) { create(:ci_build, :manual, environment: 'production', project: project) } + + before do + environment = create(:environment, name: 'production', project: project) + create(:deployment, :blocked, project: project, environment: environment, deployable: build) + end + + it { expect(subject.illustration).to include(:image, :size) } + it { expect(subject.illustration[:title]).to eq('Waiting for approval') } + it { expect(subject.illustration[:content]).to include('This job deploys to the protected environment "production"') } + end + + describe '.matches?' do + subject { described_class.matches?(build, user) } + + let(:build) { create(:ci_build, :manual, environment: 'production', project: project) } + + before do + create(:deployment, deployment_status, deployable: build, project: project) + end + + context 'when build is waiting for approval' do + let(:deployment_status) { :blocked } + + it 'is a correct match' do + expect(subject).to be_truthy + end + end + + context 'when build is not waiting for approval' do + let(:deployment_status) { :created } + + it 'does not match' do + expect(subject).to be_falsey + end + end + end +end diff --git a/spec/lib/gitlab/ci/tags/bulk_insert_spec.rb b/spec/lib/gitlab/ci/tags/bulk_insert_spec.rb index 6c1f56de840..6c4f69fb036 100644 --- a/spec/lib/gitlab/ci/tags/bulk_insert_spec.rb +++ b/spec/lib/gitlab/ci/tags/bulk_insert_spec.rb @@ -5,27 +5,37 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Tags::BulkInsert do let_it_be(:project) { create(:project, :repository) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let_it_be_with_refind(:job) { create(:ci_build, :unique_name, pipeline: pipeline, project: project) } - let_it_be_with_refind(:other_job) { create(:ci_build, :unique_name, pipeline: pipeline, project: project) } - let_it_be_with_refind(:bridge) { create(:ci_bridge, pipeline: pipeline, project: project) } + let_it_be_with_refind(:job) { create(:ci_build, :unique_name, pipeline: pipeline) } + let_it_be_with_refind(:other_job) { create(:ci_build, :unique_name, pipeline: pipeline) } - let(:statuses) { [job, bridge, other_job] } + let(:statuses) { [job, other_job] } - subject(:service) { described_class.new(statuses, tags_list) } + subject(:service) { described_class.new(statuses) } + + describe 'gem version' do + let(:acceptable_version) { '9.0.0' } + + let(:error_message) do + <<~MESSAGE + A mechanism depending on internals of 'act-as-taggable-on` has been designed + to bulk insert tags for Ci::Build records. + Please review the code carefully before updating the gem version + https://gitlab.com/gitlab-org/gitlab/-/issues/350053 + MESSAGE + end + + it { expect(ActsAsTaggableOn::VERSION).to eq(acceptable_version), error_message } + end describe '#insert!' do context 'without tags' do - let(:tags_list) { {} } - it { expect(service.insert!).to be_falsey } end context 'with tags' do - let(:tags_list) do - { - job.name => %w[tag1 tag2], - other_job.name => %w[tag2 tag3 tag4] - } + before do + job.tag_list = %w[tag1 tag2] + other_job.tag_list = %w[tag2 tag3 tag4] end it 'persists tags' do @@ -35,5 +45,18 @@ RSpec.describe Gitlab::Ci::Tags::BulkInsert do expect(other_job.reload.tag_list).to match_array(%w[tag2 tag3 tag4]) end end + + context 'with tags for only one job' do + before do + job.tag_list = %w[tag1 tag2] + end + + it 'persists tags' do + expect(service.insert!).to be_truthy + + expect(job.reload.tag_list).to match_array(%w[tag1 tag2]) + expect(other_job.reload.tag_list).to be_empty + end + end end end diff --git a/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb b/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb index 8837ebc3652..1cd88034166 100644 --- a/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb +++ b/spec/lib/gitlab/ci/trace/remote_checksum_spec.rb @@ -30,14 +30,6 @@ RSpec.describe Gitlab::Ci::Trace::RemoteChecksum do context 'with remote files' do let(:file_store) { JobArtifactUploader::Store::REMOTE } - context 'when the feature flag is disabled' do - before do - stub_feature_flags(ci_archived_build_trace_checksum: false) - end - - it { is_expected.to be_nil } - end - context 'with AWS as provider' do it { is_expected.to eq(checksum) } end diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb index 5ff34592b2f..8a87cbe45c1 100644 --- a/spec/lib/gitlab/ci/variables/builder_spec.rb +++ b/spec/lib/gitlab/ci/variables/builder_spec.rb @@ -3,25 +3,201 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Variables::Builder do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:user) { project.owner } + let_it_be(:job) do + create(:ci_build, + pipeline: pipeline, + user: user, + yaml_variables: [{ key: 'YAML_VARIABLE', value: 'value' }] + ) + end + let(:builder) { described_class.new(pipeline) } - let(:pipeline) { create(:ci_pipeline) } - let(:job) { create(:ci_build, pipeline: pipeline) } describe '#scoped_variables' do let(:environment) { job.expanded_environment_name } let(:dependencies) { true } + let(:predefined_variables) do + [ + { key: 'CI_JOB_NAME', + value: job.name }, + { key: 'CI_JOB_STAGE', + value: job.stage }, + { key: 'CI_NODE_TOTAL', + value: '1' }, + { key: 'CI_BUILD_NAME', + value: job.name }, + { key: 'CI_BUILD_STAGE', + value: job.stage }, + { key: 'CI', + value: 'true' }, + { key: 'GITLAB_CI', + value: 'true' }, + { key: 'CI_SERVER_URL', + value: Gitlab.config.gitlab.url }, + { key: 'CI_SERVER_HOST', + value: Gitlab.config.gitlab.host }, + { key: 'CI_SERVER_PORT', + value: Gitlab.config.gitlab.port.to_s }, + { key: 'CI_SERVER_PROTOCOL', + value: Gitlab.config.gitlab.protocol }, + { key: 'CI_SERVER_NAME', + value: 'GitLab' }, + { key: 'CI_SERVER_VERSION', + value: Gitlab::VERSION }, + { key: 'CI_SERVER_VERSION_MAJOR', + value: Gitlab.version_info.major.to_s }, + { key: 'CI_SERVER_VERSION_MINOR', + value: Gitlab.version_info.minor.to_s }, + { key: 'CI_SERVER_VERSION_PATCH', + value: Gitlab.version_info.patch.to_s }, + { key: 'CI_SERVER_REVISION', + value: Gitlab.revision }, + { key: 'GITLAB_FEATURES', + value: project.licensed_features.join(',') }, + { key: 'CI_PROJECT_ID', + value: project.id.to_s }, + { key: 'CI_PROJECT_NAME', + value: project.path }, + { key: 'CI_PROJECT_TITLE', + value: project.title }, + { key: 'CI_PROJECT_PATH', + value: project.full_path }, + { key: 'CI_PROJECT_PATH_SLUG', + value: project.full_path_slug }, + { key: 'CI_PROJECT_NAMESPACE', + value: project.namespace.full_path }, + { key: 'CI_PROJECT_ROOT_NAMESPACE', + value: project.namespace.root_ancestor.path }, + { key: 'CI_PROJECT_URL', + value: project.web_url }, + { key: 'CI_PROJECT_VISIBILITY', + value: "private" }, + { key: 'CI_PROJECT_REPOSITORY_LANGUAGES', + value: project.repository_languages.map(&:name).join(',').downcase }, + { key: 'CI_PROJECT_CLASSIFICATION_LABEL', + value: project.external_authorization_classification_label }, + { key: 'CI_DEFAULT_BRANCH', + value: project.default_branch }, + { key: 'CI_CONFIG_PATH', + value: project.ci_config_path_or_default }, + { key: 'CI_PAGES_DOMAIN', + value: Gitlab.config.pages.host }, + { key: 'CI_PAGES_URL', + value: project.pages_url }, + { key: 'CI_API_V4_URL', + value: API::Helpers::Version.new('v4').root_url }, + { key: 'CI_PIPELINE_IID', + value: pipeline.iid.to_s }, + { key: 'CI_PIPELINE_SOURCE', + value: pipeline.source }, + { key: 'CI_PIPELINE_CREATED_AT', + value: pipeline.created_at.iso8601 }, + { key: 'CI_COMMIT_SHA', + value: job.sha }, + { key: 'CI_COMMIT_SHORT_SHA', + value: job.short_sha }, + { key: 'CI_COMMIT_BEFORE_SHA', + value: job.before_sha }, + { key: 'CI_COMMIT_REF_NAME', + value: job.ref }, + { key: 'CI_COMMIT_REF_SLUG', + value: job.ref_slug }, + { key: 'CI_COMMIT_BRANCH', + value: job.ref }, + { key: 'CI_COMMIT_MESSAGE', + value: pipeline.git_commit_message }, + { key: 'CI_COMMIT_TITLE', + value: pipeline.git_commit_title }, + { key: 'CI_COMMIT_DESCRIPTION', + value: pipeline.git_commit_description }, + { key: 'CI_COMMIT_REF_PROTECTED', + value: (!!pipeline.protected_ref?).to_s }, + { key: 'CI_COMMIT_TIMESTAMP', + value: pipeline.git_commit_timestamp }, + { key: 'CI_COMMIT_AUTHOR', + value: pipeline.git_author_full_text }, + { key: 'CI_BUILD_REF', + value: job.sha }, + { key: 'CI_BUILD_BEFORE_SHA', + value: job.before_sha }, + { key: 'CI_BUILD_REF_NAME', + value: job.ref }, + { key: 'CI_BUILD_REF_SLUG', + value: job.ref_slug }, + { key: 'YAML_VARIABLE', + value: 'value' }, + { key: 'GITLAB_USER_ID', + value: user.id.to_s }, + { key: 'GITLAB_USER_EMAIL', + value: user.email }, + { key: 'GITLAB_USER_LOGIN', + value: user.username }, + { key: 'GITLAB_USER_NAME', + value: user.name } + ].map { |var| var.merge(public: true, masked: false) } + end subject { builder.scoped_variables(job, environment: environment, dependencies: dependencies) } - it 'returns the expected variables' do - keys = %w[CI_JOB_NAME - CI_JOB_STAGE - CI_NODE_TOTAL - CI_BUILD_NAME - CI_BUILD_STAGE] + it { is_expected.to be_instance_of(Gitlab::Ci::Variables::Collection) } + + it { expect(subject.to_runner_variables).to eq(predefined_variables) } + + context 'variables ordering' do + def var(name, value) + { key: name, value: value.to_s, public: true, masked: false } + end + + before do + allow(builder).to receive(:predefined_variables) { [var('A', 1), var('B', 1)] } + allow(project).to receive(:predefined_variables) { [var('B', 2), var('C', 2)] } + allow(pipeline).to receive(:predefined_variables) { [var('C', 3), var('D', 3)] } + allow(job).to receive(:runner) { double(predefined_variables: [var('D', 4), var('E', 4)]) } + allow(builder).to receive(:kubernetes_variables) { [var('E', 5), var('F', 5)] } + allow(builder).to receive(:deployment_variables) { [var('F', 6), var('G', 6)] } + allow(job).to receive(:yaml_variables) { [var('G', 7), var('H', 7)] } + allow(builder).to receive(:user_variables) { [var('H', 8), var('I', 8)] } + allow(job).to receive(:dependency_variables) { [var('I', 9), var('J', 9)] } + allow(builder).to receive(:secret_instance_variables) { [var('J', 10), var('K', 10)] } + allow(builder).to receive(:secret_group_variables) { [var('K', 11), var('L', 11)] } + allow(builder).to receive(:secret_project_variables) { [var('L', 12), var('M', 12)] } + allow(job).to receive(:trigger_request) { double(user_variables: [var('M', 13), var('N', 13)]) } + allow(pipeline).to receive(:variables) { [var('N', 14), var('O', 14)] } + allow(pipeline).to receive(:pipeline_schedule) { double(job_variables: [var('O', 15), var('P', 15)]) } + end + + it 'returns variables in order depending on resource hierarchy' do + expect(subject.to_runner_variables).to eq( + [var('A', 1), var('B', 1), + var('B', 2), var('C', 2), + var('C', 3), var('D', 3), + var('D', 4), var('E', 4), + var('E', 5), var('F', 5), + var('F', 6), var('G', 6), + var('G', 7), var('H', 7), + var('H', 8), var('I', 8), + var('I', 9), var('J', 9), + var('J', 10), var('K', 10), + var('K', 11), var('L', 11), + var('L', 12), var('M', 12), + var('M', 13), var('N', 13), + var('N', 14), var('O', 14), + var('O', 15), var('P', 15)]) + end - subject.map { |env| env[:key] }.tap do |names| - expect(names).to include(*keys) + it 'overrides duplicate keys depending on resource hierarchy' do + expect(subject.to_hash).to match( + 'A' => '1', 'B' => '2', + 'C' => '3', 'D' => '4', + 'E' => '5', 'F' => '6', + 'G' => '7', 'H' => '8', + 'I' => '9', 'J' => '10', + 'K' => '11', 'L' => '12', + 'M' => '13', 'N' => '14', + 'O' => '15', 'P' => '15') end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index e8b38b21ef8..20af84ce648 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -2097,6 +2097,12 @@ module Gitlab it_behaves_like 'returns errors', 'test1 job: need deploy is not defined in current or prior stages' end + context 'duplicate needs' do + let(:needs) { %w(build1 build1) } + + it_behaves_like 'returns errors', 'test1 has duplicate entries in the needs section.' + end + context 'needs and dependencies that are mismatching' do let(:needs) { %w(build1) } let(:dependencies) { %w(build2) } @@ -2602,7 +2608,7 @@ module Gitlab end context 'returns errors if job stage is not a defined stage' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", type: "acceptance" } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", type: "acceptance" } }) } it_behaves_like 'returns errors', 'rspec job: chosen stage does not exist; available stages are .pre, build, test, .post' end @@ -2638,37 +2644,37 @@ module Gitlab end context 'returns errors if job artifacts:name is not an a string' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { name: 1 } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", artifacts: { name: 1 } } }) } it_behaves_like 'returns errors', 'jobs:rspec:artifacts name should be a string' end context 'returns errors if job artifacts:when is not an a predefined value' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { when: 1 } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", artifacts: { when: 1 } } }) } it_behaves_like 'returns errors', 'jobs:rspec:artifacts when should be on_success, on_failure or always' end context 'returns errors if job artifacts:expire_in is not an a string' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: 1 } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", artifacts: { expire_in: 1 } } }) } it_behaves_like 'returns errors', 'jobs:rspec:artifacts expire in should be a duration' end context 'returns errors if job artifacts:expire_in is not an a valid duration' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) } it_behaves_like 'returns errors', 'jobs:rspec:artifacts expire in should be a duration' end context 'returns errors if job artifacts:untracked is not an array of strings' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { untracked: "string" } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", artifacts: { untracked: "string" } } }) } it_behaves_like 'returns errors', 'jobs:rspec:artifacts untracked should be a boolean value' end context 'returns errors if job artifacts:paths is not an array of strings' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { paths: "string" } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", artifacts: { paths: "string" } } }) } it_behaves_like 'returns errors', 'jobs:rspec:artifacts paths should be an array of strings' end @@ -2692,49 +2698,49 @@ module Gitlab end context 'returns errors if job cache:key is not an a string' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: 1 } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { key: 1 } } }) } it_behaves_like 'returns errors', "jobs:rspec:cache:key should be a hash, a string or a symbol" end context 'returns errors if job cache:key:files is not an array of strings' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [1] } } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { key: { files: [1] } } } }) } it_behaves_like 'returns errors', 'jobs:rspec:cache:key:files config should be an array of strings' end context 'returns errors if job cache:key:files is an empty array' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { files: [] } } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { key: { files: [] } } } }) } it_behaves_like 'returns errors', 'jobs:rspec:cache:key:files config requires at least 1 item' end context 'returns errors if job defines only cache:key:prefix' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 'prefix-key' } } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { key: { prefix: 'prefix-key' } } } }) } it_behaves_like 'returns errors', 'jobs:rspec:cache:key config missing required keys: files' end context 'returns errors if job cache:key:prefix is not an a string' do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: { prefix: 1, files: ['file'] } } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { key: { prefix: 1, files: ['file'] } } } }) } it_behaves_like 'returns errors', 'jobs:rspec:cache:key:prefix config should be a string or symbol' end context "returns errors if job cache:untracked is not an array of strings" do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { untracked: "string" } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { untracked: "string" } } }) } it_behaves_like 'returns errors', "jobs:rspec:cache:untracked config should be a boolean value" end context "returns errors if job cache:paths is not an array of strings" do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { paths: "string" } } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", cache: { paths: "string" } } }) } it_behaves_like 'returns errors', "jobs:rspec:cache:paths config should be an array of strings" end context "returns errors if job dependencies is not an array of strings" do - let(:config) { YAML.dump({ types: %w(build test), rspec: { script: "test", dependencies: "string" } }) } + let(:config) { YAML.dump({ stages: %w(build test), rspec: { script: "test", dependencies: "string" } }) } it_behaves_like 'returns errors', "jobs:rspec dependencies should be an array of strings" end diff --git a/spec/lib/gitlab/color_schemes_spec.rb b/spec/lib/gitlab/color_schemes_spec.rb index fd9fccc2bf7..feb5648ff2d 100644 --- a/spec/lib/gitlab/color_schemes_spec.rb +++ b/spec/lib/gitlab/color_schemes_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::ColorSchemes do describe '.by_id' do it 'returns a scheme by its ID' do - expect(described_class.by_id(1).name).to eq 'White' + expect(described_class.by_id(1).name).to eq 'Light' expect(described_class.by_id(4).name).to eq 'Solarized Dark' end end diff --git a/spec/lib/gitlab/config/entry/configurable_spec.rb b/spec/lib/gitlab/config/entry/configurable_spec.rb index 0153cfbf091..154038f51c7 100644 --- a/spec/lib/gitlab/config/entry/configurable_spec.rb +++ b/spec/lib/gitlab/config/entry/configurable_spec.rb @@ -39,7 +39,8 @@ RSpec.describe Gitlab::Config::Entry::Configurable do entry :object, entry_class, description: 'test object', inherit: true, - reserved: true + reserved: true, + deprecation: { deprecated: '10.0', warning: '10.1', removed: '11.0', documentation: 'docs.gitlab.com' } end end @@ -52,6 +53,12 @@ RSpec.describe Gitlab::Config::Entry::Configurable do factory = entry.nodes[:object] expect(factory).to be_an_instance_of(Gitlab::Config::Entry::Factory) + expect(factory.deprecation).to eq( + deprecated: '10.0', + warning: '10.1', + removed: '11.0', + documentation: 'docs.gitlab.com' + ) expect(factory.description).to eq('test object') expect(factory.inheritable?).to eq(true) expect(factory.reserved?).to eq(true) diff --git a/spec/lib/gitlab/config/entry/factory_spec.rb b/spec/lib/gitlab/config/entry/factory_spec.rb index a00c45169ef..260b5cf0ade 100644 --- a/spec/lib/gitlab/config/entry/factory_spec.rb +++ b/spec/lib/gitlab/config/entry/factory_spec.rb @@ -115,5 +115,16 @@ RSpec.describe Gitlab::Config::Entry::Factory do .with('some value', { some: 'hash' }) end end + + context 'when setting deprecation information' do + it 'passes deprecation as a parameter' do + entry = factory + .value('some value') + .with(deprecation: { deprecated: '10.0', warning: '10.1', removed: '11.0', documentation: 'docs' }) + .create! + + expect(entry.deprecation).to eq({ deprecated: '10.0', warning: '10.1', removed: '11.0', documentation: 'docs' }) + end + end end end diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index 56e3fc269e6..08d29f7842c 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -85,7 +85,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do expect(directives['style_src']).to eq("'self' 'unsafe-inline' https://cdn.example.com") expect(directives['font_src']).to eq("'self' https://cdn.example.com") expect(directives['worker_src']).to eq('http://localhost/assets/ blob: data: https://cdn.example.com') - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " https://cdn.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html") + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " https://cdn.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") end end @@ -113,7 +113,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do end it 'does not add CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html") + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") end end @@ -123,7 +123,7 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do end it 'adds CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html") + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") end end end diff --git a/spec/lib/gitlab/data_builder/archive_trace_spec.rb b/spec/lib/gitlab/data_builder/archive_trace_spec.rb new file mode 100644 index 00000000000..a310b0f0a94 --- /dev/null +++ b/spec/lib/gitlab/data_builder/archive_trace_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::DataBuilder::ArchiveTrace do + let_it_be(:build) { create(:ci_build, :trace_artifact) } + + describe '.build' do + let(:data) { described_class.build(build) } + + it 'has correct attributes', :aggregate_failures do + expect(data[:object_kind]).to eq 'archive_trace' + expect(data[:trace_url]).to eq build.job_artifacts_trace.file.url + expect(data[:build_id]).to eq build.id + expect(data[:pipeline_id]).to eq build.pipeline_id + expect(data[:project]).to eq build.project.hook_attrs + end + end +end diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb index 75741c52579..ab8c8a51694 100644 --- a/spec/lib/gitlab/data_builder/deployment_spec.rb +++ b/spec/lib/gitlab/data_builder/deployment_spec.rb @@ -37,6 +37,7 @@ RSpec.describe Gitlab::DataBuilder::Deployment do expect(data[:user_url]).to eq(expected_user_url) expect(data[:commit_url]).to eq(expected_commit_url) expect(data[:commit_title]).to eq(commit.title) + expect(data[:ref]).to eq(deployment.ref) end it 'does not include the deployable URL when there is no deployable' do diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb index 49714cfc4dd..01d61a525e6 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb @@ -336,8 +336,8 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end describe '#smoothed_time_efficiency' do - let(:migration) { create(:batched_background_migration, interval: 120.seconds) } - let(:end_time) { Time.zone.now } + let_it_be(:migration) { create(:batched_background_migration, interval: 120.seconds) } + let_it_be(:end_time) { Time.zone.now } around do |example| freeze_time do @@ -345,7 +345,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end end - let(:common_attrs) do + let_it_be(:common_attrs) do { status: :succeeded, batched_migration: migration, @@ -364,13 +364,14 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end context 'when there are enough jobs' do - subject { migration.smoothed_time_efficiency(number_of_jobs: number_of_jobs) } + let_it_be(:number_of_jobs) { 10 } + let_it_be(:jobs) { create_list(:batched_background_migration_job, number_of_jobs, **common_attrs.merge(batched_migration: migration)) } - let!(:jobs) { create_list(:batched_background_migration_job, number_of_jobs, **common_attrs.merge(batched_migration: migration)) } - let(:number_of_jobs) { 10 } + subject { migration.smoothed_time_efficiency(number_of_jobs: number_of_jobs) } before do - expect(migration).to receive_message_chain(:batched_jobs, :successful_in_execution_order, :reverse_order, :limit).with(no_args).with(no_args).with(number_of_jobs).and_return(jobs) + expect(migration).to receive_message_chain(:batched_jobs, :successful_in_execution_order, :reverse_order, :limit, :with_preloads) + .and_return(jobs) end def mock_efficiencies(*effs) @@ -411,6 +412,18 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end end end + + context 'with preloaded batched migration' do + it 'avoids N+1' do + create_list(:batched_background_migration_job, 11, **common_attrs.merge(started_at: end_time - 10.seconds)) + + control = ActiveRecord::QueryRecorder.new do + migration.smoothed_time_efficiency(number_of_jobs: 10) + end + + expect { migration.smoothed_time_efficiency(number_of_jobs: 11) }.not_to exceed_query_limit(control) + end + end end describe '#optimize!' do diff --git a/spec/lib/gitlab/database/background_migration_job_spec.rb b/spec/lib/gitlab/database/background_migration_job_spec.rb index 42695925a1c..1117c17c84a 100644 --- a/spec/lib/gitlab/database/background_migration_job_spec.rb +++ b/spec/lib/gitlab/database/background_migration_job_spec.rb @@ -5,6 +5,8 @@ require 'spec_helper' RSpec.describe Gitlab::Database::BackgroundMigrationJob do it_behaves_like 'having unique enum values' + it { is_expected.to be_a Gitlab::Database::SharedModel } + describe '.for_migration_execution' do let!(:job1) { create(:background_migration_job) } let!(:job2) { create(:background_migration_job, arguments: ['hi', 2]) } diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb index 9831510f014..028bdce852e 100644 --- a/spec/lib/gitlab/database/batch_count_spec.rb +++ b/spec/lib/gitlab/database/batch_count_spec.rb @@ -270,8 +270,6 @@ RSpec.describe Gitlab::Database::BatchCount do end it "defaults the batch size to #{Gitlab::Database::BatchCounter::DEFAULT_DISTINCT_BATCH_SIZE}" do - stub_feature_flags(loose_index_scan_for_distinct_values: false) - min_id = model.minimum(:id) relation = instance_double(ActiveRecord::Relation) allow(model).to receive_message_chain(:select, public_send: relation) @@ -317,85 +315,13 @@ RSpec.describe Gitlab::Database::BatchCount do end end - context 'when the loose_index_scan_for_distinct_values feature flag is off' do - it_behaves_like 'when batch fetch query is canceled' do - let(:mode) { :distinct } - let(:operation) { :count } - let(:operation_args) { nil } - let(:column) { nil } - - subject { described_class.method(:batch_distinct_count) } - - before do - stub_feature_flags(loose_index_scan_for_distinct_values: false) - end - end - end - - context 'when the loose_index_scan_for_distinct_values feature flag is on' do + it_behaves_like 'when batch fetch query is canceled' do let(:mode) { :distinct } let(:operation) { :count } let(:operation_args) { nil } let(:column) { nil } - let(:batch_size) { 10_000 } - subject { described_class.method(:batch_distinct_count) } - - before do - stub_feature_flags(loose_index_scan_for_distinct_values: true) - end - - it 'reduces batch size by half and retry fetch' do - too_big_batch_relation_mock = instance_double(ActiveRecord::Relation) - - count_method = double(send: 1) - - allow(too_big_batch_relation_mock).to receive(:send).and_raise(ActiveRecord::QueryCanceled) - allow(Gitlab::Database::LooseIndexScanDistinctCount).to receive_message_chain(:new, :build_query).with(from: 0, to: batch_size).and_return(too_big_batch_relation_mock) - allow(Gitlab::Database::LooseIndexScanDistinctCount).to receive_message_chain(:new, :build_query).with(from: 0, to: batch_size / 2).and_return(count_method) - allow(Gitlab::Database::LooseIndexScanDistinctCount).to receive_message_chain(:new, :build_query).with(from: batch_size / 2, to: batch_size).and_return(count_method) - - subject.call(model, column, batch_size: batch_size, start: 0, finish: batch_size - 1) - end - - context 'when all retries fail' do - let(:batch_count_query) { 'SELECT COUNT(id) FROM relation WHERE id BETWEEN 0 and 1' } - - before do - relation = instance_double(ActiveRecord::Relation) - allow(Gitlab::Database::LooseIndexScanDistinctCount).to receive_message_chain(:new, :build_query).and_return(relation) - allow(relation).to receive(:send).and_raise(ActiveRecord::QueryCanceled.new('query timed out')) - allow(relation).to receive(:to_sql).and_return(batch_count_query) - end - - it 'logs failing query' do - expect(Gitlab::AppJsonLogger).to receive(:error).with( - event: 'batch_count', - relation: model.table_name, - operation: operation, - operation_args: operation_args, - start: 0, - mode: mode, - query: batch_count_query, - message: 'Query has been canceled with message: query timed out' - ) - expect(subject.call(model, column, batch_size: batch_size, start: 0)).to eq(-1) - end - end - - context 'when LooseIndexScanDistinctCount raises error' do - let(:column) { :creator_id } - let(:error_class) { Gitlab::Database::LooseIndexScanDistinctCount::ColumnConfigurationError } - - it 'rescues ColumnConfigurationError' do - allow(Gitlab::Database::LooseIndexScanDistinctCount).to receive(:new).and_raise(error_class.new('error message')) - - expect(Gitlab::AppJsonLogger).to receive(:error).with(a_hash_including(message: 'LooseIndexScanDistinctCount column error: error message')) - - expect(subject.call(Project, column, batch_size: 10_000, start: 0)).to eq(-1) - end - end end end diff --git a/spec/lib/gitlab/database/bulk_update_spec.rb b/spec/lib/gitlab/database/bulk_update_spec.rb index 9a6463c99fa..08b4d50f83b 100644 --- a/spec/lib/gitlab/database/bulk_update_spec.rb +++ b/spec/lib/gitlab/database/bulk_update_spec.rb @@ -101,7 +101,7 @@ RSpec.describe Gitlab::Database::BulkUpdate do before do configuration_hash = ActiveRecord::Base.connection_db_config.configuration_hash - ActiveRecord::Base.establish_connection( + ActiveRecord::Base.establish_connection( # rubocop: disable Database/EstablishConnection configuration_hash.merge(prepared_statements: prepared_statements) ) end diff --git a/spec/lib/gitlab/database/loose_index_scan_distinct_count_spec.rb b/spec/lib/gitlab/database/loose_index_scan_distinct_count_spec.rb deleted file mode 100644 index e0eac26e4d9..00000000000 --- a/spec/lib/gitlab/database/loose_index_scan_distinct_count_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Database::LooseIndexScanDistinctCount do - context 'counting distinct users' do - let_it_be(:user) { create(:user) } - let_it_be(:other_user) { create(:user) } - - let(:column) { :creator_id } - - before_all do - create_list(:project, 3, creator: user) - create_list(:project, 1, creator: other_user) - end - - subject(:count) { described_class.new(Project, :creator_id).count(from: Project.minimum(:creator_id), to: Project.maximum(:creator_id) + 1) } - - it { is_expected.to eq(2) } - - context 'when STI model is queried' do - it 'does not raise error' do - expect { described_class.new(Group, :owner_id).count(from: 0, to: 1) }.not_to raise_error - end - end - - context 'when model with default_scope is queried' do - it 'does not raise error' do - expect { described_class.new(GroupMember, :id).count(from: 0, to: 1) }.not_to raise_error - end - end - - context 'when the fully qualified column is given' do - let(:column) { 'projects.creator_id' } - - it { is_expected.to eq(2) } - end - - context 'when AR attribute is given' do - let(:column) { Project.arel_table[:creator_id] } - - it { is_expected.to eq(2) } - end - - context 'when invalid value is given for the column' do - let(:column) { Class.new } - - it { expect { described_class.new(Group, column) }.to raise_error(Gitlab::Database::LooseIndexScanDistinctCount::ColumnConfigurationError) } - end - - context 'when null values are present' do - before do - create_list(:project, 2).each { |p| p.update_column(:creator_id, nil) } - end - - it { is_expected.to eq(2) } - end - end - - context 'counting STI models' do - let!(:groups) { create_list(:group, 3) } - let!(:namespaces) { create_list(:namespace, 2) } - - let(:max_id) { Namespace.maximum(:id) + 1 } - - it 'counts groups' do - count = described_class.new(Group, :id).count(from: 0, to: max_id) - expect(count).to eq(3) - end - end -end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 7f80bed04a4..7e3de32b965 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1752,116 +1752,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end - describe '#change_column_type_using_background_migration' do - let!(:issue) { create(:issue, :closed, closed_at: Time.zone.now) } - - let(:issue_model) do - Class.new(ActiveRecord::Base) do - self.table_name = 'issues' - include EachBatch - end - end - - it 'changes the type of a column using a background migration' do - expect(model) - .to receive(:add_column) - .with('issues', 'closed_at_for_type_change', :datetime_with_timezone) - - expect(model) - .to receive(:install_rename_triggers) - .with('issues', :closed_at, 'closed_at_for_type_change') - - expect(BackgroundMigrationWorker) - .to receive(:perform_in) - .ordered - .with( - 10.minutes, - 'CopyColumn', - ['issues', :closed_at, 'closed_at_for_type_change', issue.id, issue.id] - ) - - expect(BackgroundMigrationWorker) - .to receive(:perform_in) - .ordered - .with( - 1.hour + 10.minutes, - 'CleanupConcurrentTypeChange', - ['issues', :closed_at, 'closed_at_for_type_change'] - ) - - expect(Gitlab::BackgroundMigration) - .to receive(:steal) - .ordered - .with('CopyColumn') - - expect(Gitlab::BackgroundMigration) - .to receive(:steal) - .ordered - .with('CleanupConcurrentTypeChange') - - model.change_column_type_using_background_migration( - issue_model.all, - :closed_at, - :datetime_with_timezone - ) - end - end - - describe '#rename_column_using_background_migration' do - let!(:issue) { create(:issue, :closed, closed_at: Time.zone.now) } - - it 'renames a column using a background migration' do - expect(model) - .to receive(:add_column) - .with( - 'issues', - :closed_at_timestamp, - :datetime_with_timezone, - limit: anything, - precision: anything, - scale: anything - ) - - expect(model) - .to receive(:install_rename_triggers) - .with('issues', :closed_at, :closed_at_timestamp) - - expect(BackgroundMigrationWorker) - .to receive(:perform_in) - .ordered - .with( - 10.minutes, - 'CopyColumn', - ['issues', :closed_at, :closed_at_timestamp, issue.id, issue.id] - ) - - expect(BackgroundMigrationWorker) - .to receive(:perform_in) - .ordered - .with( - 1.hour + 10.minutes, - 'CleanupConcurrentRename', - ['issues', :closed_at, :closed_at_timestamp] - ) - - expect(Gitlab::BackgroundMigration) - .to receive(:steal) - .ordered - .with('CopyColumn') - - expect(Gitlab::BackgroundMigration) - .to receive(:steal) - .ordered - .with('CleanupConcurrentRename') - - model.rename_column_using_background_migration( - 'issues', - :closed_at, - :closed_at_timestamp - ) - end - end - describe '#convert_to_bigint_column' do it 'returns the name of the temporary column used to convert to bigint' do expect(model.convert_to_bigint_column(:id)).to eq('id_convert_to_bigint') @@ -2065,8 +1955,6 @@ RSpec.describe Gitlab::Database::MigrationHelpers do t.integer :other_id t.timestamps end - - allow(model).to receive(:perform_background_migration_inline?).and_return(false) end context 'when the target table does not exist' do diff --git a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb index 99c7d70724c..0abb76b9f8a 100644 --- a/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migrations/background_migration_helpers_spec.rb @@ -7,249 +7,208 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do ActiveRecord::Migration.new.extend(described_class) end - describe '#queue_background_migration_jobs_by_range_at_intervals' do - context 'when the model has an ID column' do - let!(:id1) { create(:user).id } - let!(:id2) { create(:user).id } - let!(:id3) { create(:user).id } - - around do |example| - freeze_time { example.run } - end - - before do - User.class_eval do - include EachBatch - end - end + shared_examples_for 'helpers that enqueue background migrations' do |worker_class, tracking_database| + before do + allow(model).to receive(:tracking_database).and_return(tracking_database) + end - it 'returns the final expected delay' do - Sidekiq::Testing.fake! do - final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, batch_size: 2) + describe '#queue_background_migration_jobs_by_range_at_intervals' do + context 'when the model has an ID column' do + let!(:id1) { create(:user).id } + let!(:id2) { create(:user).id } + let!(:id3) { create(:user).id } - expect(final_delay.to_f).to eq(20.minutes.to_f) + around do |example| + freeze_time { example.run } end - end - - it 'returns zero when nothing gets queued' do - Sidekiq::Testing.fake! do - final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User.none, 'FooJob', 10.minutes) - expect(final_delay).to eq(0) + before do + User.class_eval do + include EachBatch + end end - end - context 'with batch_size option' do - it 'queues jobs correctly' do + it 'returns the final expected delay' do Sidekiq::Testing.fake! do - model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, batch_size: 2) + final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, batch_size: 2) - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f) - expect(BackgroundMigrationWorker.jobs[1]['args']).to eq(['FooJob', [id3, id3]]) - expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(20.minutes.from_now.to_f) + expect(final_delay.to_f).to eq(20.minutes.to_f) end end - end - context 'without batch_size option' do - it 'queues jobs correctly' do + it 'returns zero when nothing gets queued' do Sidekiq::Testing.fake! do - model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes) + final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User.none, 'FooJob', 10.minutes) - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f) + expect(final_delay).to eq(0) end end - end - context 'with other_job_arguments option' do - it 'queues jobs correctly' do - Sidekiq::Testing.fake! do - model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2]) + context 'when the delay_interval is smaller than the minimum' do + it 'sets the delay_interval to the minimum value' do + Sidekiq::Testing.fake! do + final_delay = model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 1.minute, batch_size: 2) - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f) + expect(worker_class.jobs[0]['args']).to eq(['FooJob', [id1, id2]]) + expect(worker_class.jobs[0]['at']).to eq(2.minutes.from_now.to_f) + expect(worker_class.jobs[1]['args']).to eq(['FooJob', [id3, id3]]) + expect(worker_class.jobs[1]['at']).to eq(4.minutes.from_now.to_f) + + expect(final_delay.to_f).to eq(4.minutes.to_f) + end end end - end - context 'with initial_delay option' do - it 'queues jobs correctly' do - Sidekiq::Testing.fake! do - model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2], initial_delay: 10.minutes) + context 'with batch_size option' do + it 'queues jobs correctly' do + Sidekiq::Testing.fake! do + model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, batch_size: 2) - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(20.minutes.from_now.to_f) + expect(worker_class.jobs[0]['args']).to eq(['FooJob', [id1, id2]]) + expect(worker_class.jobs[0]['at']).to eq(10.minutes.from_now.to_f) + expect(worker_class.jobs[1]['args']).to eq(['FooJob', [id3, id3]]) + expect(worker_class.jobs[1]['at']).to eq(20.minutes.from_now.to_f) + end end end - end - - context 'with track_jobs option' do - it 'creates a record for each job in the database' do - Sidekiq::Testing.fake! do - expect do - model.queue_background_migration_jobs_by_range_at_intervals(User, '::FooJob', 10.minutes, - other_job_arguments: [1, 2], track_jobs: true) - end.to change { Gitlab::Database::BackgroundMigrationJob.count }.from(0).to(1) - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - tracked_job = Gitlab::Database::BackgroundMigrationJob.first + context 'without batch_size option' do + it 'queues jobs correctly' do + Sidekiq::Testing.fake! do + model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes) - expect(tracked_job.class_name).to eq('FooJob') - expect(tracked_job.arguments).to eq([id1, id3, 1, 2]) - expect(tracked_job).to be_pending + expect(worker_class.jobs[0]['args']).to eq(['FooJob', [id1, id3]]) + expect(worker_class.jobs[0]['at']).to eq(10.minutes.from_now.to_f) + end end end - end - context 'without track_jobs option' do - it 'does not create records in the database' do - Sidekiq::Testing.fake! do - expect do + context 'with other_job_arguments option' do + it 'queues jobs correctly' do + Sidekiq::Testing.fake! do model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2]) - end.not_to change { Gitlab::Database::BackgroundMigrationJob.count } - expect(BackgroundMigrationWorker.jobs.size).to eq(1) + expect(worker_class.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]]) + expect(worker_class.jobs[0]['at']).to eq(10.minutes.from_now.to_f) + end end end - end - end - - context 'when the model specifies a primary_column_name' do - let!(:id1) { create(:container_expiration_policy).id } - let!(:id2) { create(:container_expiration_policy).id } - let!(:id3) { create(:container_expiration_policy).id } - around do |example| - freeze_time { example.run } - end + context 'with initial_delay option' do + it 'queues jobs correctly' do + Sidekiq::Testing.fake! do + model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2], initial_delay: 10.minutes) - before do - ContainerExpirationPolicy.class_eval do - include EachBatch + expect(worker_class.jobs[0]['args']).to eq(['FooJob', [id1, id3, 1, 2]]) + expect(worker_class.jobs[0]['at']).to eq(20.minutes.from_now.to_f) + end + end end - end - it 'returns the final expected delay', :aggregate_failures do - Sidekiq::Testing.fake! do - final_delay = model.queue_background_migration_jobs_by_range_at_intervals(ContainerExpirationPolicy, 'FooJob', 10.minutes, batch_size: 2, primary_column_name: :project_id) + context 'with track_jobs option' do + it 'creates a record for each job in the database' do + Sidekiq::Testing.fake! do + expect do + model.queue_background_migration_jobs_by_range_at_intervals(User, '::FooJob', 10.minutes, + other_job_arguments: [1, 2], track_jobs: true) + end.to change { Gitlab::Database::BackgroundMigrationJob.count }.from(0).to(1) - expect(final_delay.to_f).to eq(20.minutes.to_f) - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['FooJob', [id1, id2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(10.minutes.from_now.to_f) - expect(BackgroundMigrationWorker.jobs[1]['args']).to eq(['FooJob', [id3, id3]]) - expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(20.minutes.from_now.to_f) - end - end + expect(worker_class.jobs.size).to eq(1) - context "when the primary_column_name is not an integer" do - it 'raises error' do - expect do - model.queue_background_migration_jobs_by_range_at_intervals(ContainerExpirationPolicy, 'FooJob', 10.minutes, primary_column_name: :enabled) - end.to raise_error(StandardError, /is not an integer column/) - end - end + tracked_job = Gitlab::Database::BackgroundMigrationJob.first - context "when the primary_column_name does not exist" do - it 'raises error' do - expect do - model.queue_background_migration_jobs_by_range_at_intervals(ContainerExpirationPolicy, 'FooJob', 10.minutes, primary_column_name: :foo) - end.to raise_error(StandardError, /does not have an ID column of foo/) + expect(tracked_job.class_name).to eq('FooJob') + expect(tracked_job.arguments).to eq([id1, id3, 1, 2]) + expect(tracked_job).to be_pending + end + end end - end - end - - context "when the model doesn't have an ID or primary_column_name column" do - it 'raises error (for now)' do - expect do - model.queue_background_migration_jobs_by_range_at_intervals(ProjectAuthorization, 'FooJob', 10.seconds) - end.to raise_error(StandardError, /does not have an ID/) - end - end - end - describe '#requeue_background_migration_jobs_by_range_at_intervals' do - let!(:job_class_name) { 'TestJob' } - let!(:pending_job_1) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [1, 2]) } - let!(:pending_job_2) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [3, 4]) } - let!(:successful_job_1) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [5, 6]) } - let!(:successful_job_2) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [7, 8]) } + context 'without track_jobs option' do + it 'does not create records in the database' do + Sidekiq::Testing.fake! do + expect do + model.queue_background_migration_jobs_by_range_at_intervals(User, 'FooJob', 10.minutes, other_job_arguments: [1, 2]) + end.not_to change { Gitlab::Database::BackgroundMigrationJob.count } - around do |example| - freeze_time do - Sidekiq::Testing.fake! do - example.run + expect(worker_class.jobs.size).to eq(1) + end + end end end - end - - subject { model.requeue_background_migration_jobs_by_range_at_intervals(job_class_name, 10.minutes) } - - it 'returns the expected duration' do - expect(subject).to eq(20.minutes) - end - context 'when nothing is queued' do - subject { model.requeue_background_migration_jobs_by_range_at_intervals('FakeJob', 10.minutes) } + context 'when the model specifies a primary_column_name' do + let!(:id1) { create(:container_expiration_policy).id } + let!(:id2) { create(:container_expiration_policy).id } + let!(:id3) { create(:container_expiration_policy).id } - it 'returns expected duration of zero when nothing gets queued' do - expect(subject).to eq(0) - end - end - - it 'queues pending jobs' do - subject + around do |example| + freeze_time { example.run } + end - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([job_class_name, [1, 2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to be_nil - expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([job_class_name, [3, 4]]) - expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(10.minutes.from_now.to_f) - end + before do + ContainerExpirationPolicy.class_eval do + include EachBatch + end + end - context 'with batch_size option' do - subject { model.requeue_background_migration_jobs_by_range_at_intervals(job_class_name, 10.minutes, batch_size: 1) } + it 'returns the final expected delay', :aggregate_failures do + Sidekiq::Testing.fake! do + final_delay = model.queue_background_migration_jobs_by_range_at_intervals(ContainerExpirationPolicy, 'FooJob', 10.minutes, batch_size: 2, primary_column_name: :project_id) - it 'returns the expected duration' do - expect(subject).to eq(20.minutes) - end + expect(final_delay.to_f).to eq(20.minutes.to_f) + expect(worker_class.jobs[0]['args']).to eq(['FooJob', [id1, id2]]) + expect(worker_class.jobs[0]['at']).to eq(10.minutes.from_now.to_f) + expect(worker_class.jobs[1]['args']).to eq(['FooJob', [id3, id3]]) + expect(worker_class.jobs[1]['at']).to eq(20.minutes.from_now.to_f) + end + end - it 'queues pending jobs' do - subject + context "when the primary_column_name is not an integer" do + it 'raises error' do + expect do + model.queue_background_migration_jobs_by_range_at_intervals(ContainerExpirationPolicy, 'FooJob', 10.minutes, primary_column_name: :enabled) + end.to raise_error(StandardError, /is not an integer column/) + end + end - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([job_class_name, [1, 2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to be_nil - expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([job_class_name, [3, 4]]) - expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(10.minutes.from_now.to_f) + context "when the primary_column_name does not exist" do + it 'raises error' do + expect do + model.queue_background_migration_jobs_by_range_at_intervals(ContainerExpirationPolicy, 'FooJob', 10.minutes, primary_column_name: :foo) + end.to raise_error(StandardError, /does not have an ID column of foo/) + end + end end - it 'retrieve jobs in batches' do - jobs = double('jobs') - expect(Gitlab::Database::BackgroundMigrationJob).to receive(:pending) { jobs } - allow(jobs).to receive(:where).with(class_name: job_class_name) { jobs } - expect(jobs).to receive(:each_batch).with(of: 1) - - subject + context "when the model doesn't have an ID or primary_column_name column" do + it 'raises error (for now)' do + expect do + model.queue_background_migration_jobs_by_range_at_intervals(ProjectAuthorization, 'FooJob', 10.seconds) + end.to raise_error(StandardError, /does not have an ID/) + end end end - context 'with initial_delay option' do - let_it_be(:initial_delay) { 3.minutes } + describe '#requeue_background_migration_jobs_by_range_at_intervals' do + let!(:job_class_name) { 'TestJob' } + let!(:pending_job_1) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [1, 2]) } + let!(:pending_job_2) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [3, 4]) } + let!(:successful_job_1) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [5, 6]) } + let!(:successful_job_2) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [7, 8]) } - subject { model.requeue_background_migration_jobs_by_range_at_intervals(job_class_name, 10.minutes, initial_delay: initial_delay) } - - it 'returns the expected duration' do - expect(subject).to eq(23.minutes) + around do |example| + freeze_time do + Sidekiq::Testing.fake! do + example.run + end + end end - it 'queues pending jobs' do - subject + subject { model.requeue_background_migration_jobs_by_range_at_intervals(job_class_name, 10.minutes) } - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq([job_class_name, [1, 2]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to eq(3.minutes.from_now.to_f) - expect(BackgroundMigrationWorker.jobs[1]['args']).to eq([job_class_name, [3, 4]]) - expect(BackgroundMigrationWorker.jobs[1]['at']).to eq(13.minutes.from_now.to_f) + it 'returns the expected duration' do + expect(subject).to eq(20.minutes) end context 'when nothing is queued' do @@ -259,195 +218,226 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do expect(subject).to eq(0) end end - end - end - describe '#perform_background_migration_inline?' do - it 'returns true in a test environment' do - stub_rails_env('test') + it 'queues pending jobs' do + subject - expect(model.perform_background_migration_inline?).to eq(true) - end + expect(worker_class.jobs[0]['args']).to eq([job_class_name, [1, 2]]) + expect(worker_class.jobs[0]['at']).to be_nil + expect(worker_class.jobs[1]['args']).to eq([job_class_name, [3, 4]]) + expect(worker_class.jobs[1]['at']).to eq(10.minutes.from_now.to_f) + end - it 'returns true in a development environment' do - stub_rails_env('development') + context 'with batch_size option' do + subject { model.requeue_background_migration_jobs_by_range_at_intervals(job_class_name, 10.minutes, batch_size: 1) } - expect(model.perform_background_migration_inline?).to eq(true) - end + it 'returns the expected duration' do + expect(subject).to eq(20.minutes) + end - it 'returns false in a production environment' do - stub_rails_env('production') + it 'queues pending jobs' do + subject - expect(model.perform_background_migration_inline?).to eq(false) - end - end + expect(worker_class.jobs[0]['args']).to eq([job_class_name, [1, 2]]) + expect(worker_class.jobs[0]['at']).to be_nil + expect(worker_class.jobs[1]['args']).to eq([job_class_name, [3, 4]]) + expect(worker_class.jobs[1]['at']).to eq(10.minutes.from_now.to_f) + end - describe '#migrate_async' do - it 'calls BackgroundMigrationWorker.perform_async' do - expect(BackgroundMigrationWorker).to receive(:perform_async).with("Class", "hello", "world") + it 'retrieve jobs in batches' do + jobs = double('jobs') + expect(Gitlab::Database::BackgroundMigrationJob).to receive(:pending) { jobs } + allow(jobs).to receive(:where).with(class_name: job_class_name) { jobs } + expect(jobs).to receive(:each_batch).with(of: 1) - model.migrate_async("Class", "hello", "world") - end + subject + end + end - it 'pushes a context with the current class name as caller_id' do - expect(Gitlab::ApplicationContext).to receive(:with_context).with(caller_id: model.class.to_s) + context 'with initial_delay option' do + let_it_be(:initial_delay) { 3.minutes } - model.migrate_async('Class', 'hello', 'world') - end - end + subject { model.requeue_background_migration_jobs_by_range_at_intervals(job_class_name, 10.minutes, initial_delay: initial_delay) } - describe '#migrate_in' do - it 'calls BackgroundMigrationWorker.perform_in' do - expect(BackgroundMigrationWorker).to receive(:perform_in).with(10.minutes, 'Class', 'Hello', 'World') + it 'returns the expected duration' do + expect(subject).to eq(23.minutes) + end - model.migrate_in(10.minutes, 'Class', 'Hello', 'World') - end + it 'queues pending jobs' do + subject + + expect(worker_class.jobs[0]['args']).to eq([job_class_name, [1, 2]]) + expect(worker_class.jobs[0]['at']).to eq(3.minutes.from_now.to_f) + expect(worker_class.jobs[1]['args']).to eq([job_class_name, [3, 4]]) + expect(worker_class.jobs[1]['at']).to eq(13.minutes.from_now.to_f) + end - it 'pushes a context with the current class name as caller_id' do - expect(Gitlab::ApplicationContext).to receive(:with_context).with(caller_id: model.class.to_s) + context 'when nothing is queued' do + subject { model.requeue_background_migration_jobs_by_range_at_intervals('FakeJob', 10.minutes) } - model.migrate_in(10.minutes, 'Class', 'Hello', 'World') + it 'returns expected duration of zero when nothing gets queued' do + expect(subject).to eq(0) + end + end + end end - end - describe '#bulk_migrate_async' do - it 'calls BackgroundMigrationWorker.bulk_perform_async' do - expect(BackgroundMigrationWorker).to receive(:bulk_perform_async).with([%w(Class hello world)]) + describe '#finalized_background_migration' do + let(:coordinator) { Gitlab::BackgroundMigration::JobCoordinator.new(worker_class) } - model.bulk_migrate_async([%w(Class hello world)]) - end + let!(:tracked_pending_job) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [1]) } + let!(:tracked_successful_job) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [2]) } + let!(:job_class_name) { 'TestJob' } - it 'pushes a context with the current class name as caller_id' do - expect(Gitlab::ApplicationContext).to receive(:with_context).with(caller_id: model.class.to_s) + let!(:job_class) do + Class.new do + def perform(*arguments) + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded('TestJob', arguments) + end + end + end - model.bulk_migrate_async([%w(Class hello world)]) - end - end + before do + allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) + .with('main').and_return(coordinator) - describe '#bulk_migrate_in' do - it 'calls BackgroundMigrationWorker.bulk_perform_in_' do - expect(BackgroundMigrationWorker).to receive(:bulk_perform_in).with(10.minutes, [%w(Class hello world)]) + expect(coordinator).to receive(:migration_class_for) + .with(job_class_name).at_least(:once) { job_class } - model.bulk_migrate_in(10.minutes, [%w(Class hello world)]) - end + Sidekiq::Testing.disable! do + worker_class.perform_async(job_class_name, [1, 2]) + worker_class.perform_async(job_class_name, [3, 4]) + worker_class.perform_in(10, job_class_name, [5, 6]) + worker_class.perform_in(20, job_class_name, [7, 8]) + end + end - it 'pushes a context with the current class name as caller_id' do - expect(Gitlab::ApplicationContext).to receive(:with_context).with(caller_id: model.class.to_s) + it_behaves_like 'finalized tracked background migration', worker_class do + before do + model.finalize_background_migration(job_class_name) + end + end - model.bulk_migrate_in(10.minutes, [%w(Class hello world)]) - end - end + context 'when removing all tracked job records' do + let!(:job_class) do + Class.new do + def perform(*arguments) + # Force pending jobs to remain pending + end + end + end - describe '#delete_queued_jobs' do - let(:job1) { double } - let(:job2) { double } + before do + model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded]) + end - it 'deletes all queued jobs for the given background migration' do - expect(Gitlab::BackgroundMigration).to receive(:steal).with('BackgroundMigrationClassName') do |&block| - expect(block.call(job1)).to be(false) - expect(block.call(job2)).to be(false) + it_behaves_like 'finalized tracked background migration', worker_class + it_behaves_like 'removed tracked jobs', 'pending' + it_behaves_like 'removed tracked jobs', 'succeeded' end - expect(job1).to receive(:delete) - expect(job2).to receive(:delete) + context 'when retaining all tracked job records' do + before do + model.finalize_background_migration(job_class_name, delete_tracking_jobs: false) + end - model.delete_queued_jobs('BackgroundMigrationClassName') - end - end + it_behaves_like 'finalized background migration', worker_class + include_examples 'retained tracked jobs', 'succeeded' + end - describe '#finalized_background_migration' do - let(:job_coordinator) { Gitlab::BackgroundMigration::JobCoordinator.new(BackgroundMigrationWorker) } + context 'during retry race condition' do + let!(:job_class) do + Class.new do + class << self + attr_accessor :worker_class - let!(:job_class_name) { 'TestJob' } - let!(:job_class) { Class.new } - let!(:job_perform_method) do - ->(*arguments) do - Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded( - # Value is 'TestJob' defined by :job_class_name in the let! above. - # Scoping prohibits us from directly referencing job_class_name. - RSpec.current_example.example_group_instance.job_class_name, - arguments - ) - end - end + def queue_items_added + @queue_items_added ||= [] + end + end - let!(:tracked_pending_job) { create(:background_migration_job, class_name: job_class_name, status: :pending, arguments: [1]) } - let!(:tracked_successful_job) { create(:background_migration_job, class_name: job_class_name, status: :succeeded, arguments: [2]) } + def worker_class + self.class.worker_class + end - before do - job_class.define_method(:perform, job_perform_method) + def queue_items_added + self.class.queue_items_added + end - allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) - .with('main').and_return(job_coordinator) + def perform(*arguments) + Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded('TestJob', arguments) - expect(job_coordinator).to receive(:migration_class_for) - .with(job_class_name).at_least(:once) { job_class } + # Mock another process pushing queue jobs. + if self.class.queue_items_added.count < 10 + Sidekiq::Testing.disable! do + queue_items_added << worker_class.perform_async('TestJob', [Time.current]) + queue_items_added << worker_class.perform_in(10, 'TestJob', [Time.current]) + end + end + end + end + end - Sidekiq::Testing.disable! do - BackgroundMigrationWorker.perform_async(job_class_name, [1, 2]) - BackgroundMigrationWorker.perform_async(job_class_name, [3, 4]) - BackgroundMigrationWorker.perform_in(10, job_class_name, [5, 6]) - BackgroundMigrationWorker.perform_in(20, job_class_name, [7, 8]) - end - end + it_behaves_like 'finalized tracked background migration', worker_class do + before do + # deliberately set the worker class on our test job since it won't be pulled from the surrounding scope + job_class.worker_class = worker_class - it_behaves_like 'finalized tracked background migration' do - before do - model.finalize_background_migration(job_class_name) + model.finalize_background_migration(job_class_name, delete_tracking_jobs: ['succeeded']) + end + end end end - context 'when removing all tracked job records' do - # Force pending jobs to remain pending. - let!(:job_perform_method) { ->(*arguments) { } } + describe '#migrate_in' do + it 'calls perform_in for the correct worker' do + expect(worker_class).to receive(:perform_in).with(10.minutes, 'Class', 'Hello', 'World') - before do - model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded]) + model.migrate_in(10.minutes, 'Class', 'Hello', 'World') end - it_behaves_like 'finalized tracked background migration' - it_behaves_like 'removed tracked jobs', 'pending' - it_behaves_like 'removed tracked jobs', 'succeeded' - end + it 'pushes a context with the current class name as caller_id' do + expect(Gitlab::ApplicationContext).to receive(:with_context).with(caller_id: model.class.to_s) - context 'when retaining all tracked job records' do - before do - model.finalize_background_migration(job_class_name, delete_tracking_jobs: false) + model.migrate_in(10.minutes, 'Class', 'Hello', 'World') end - it_behaves_like 'finalized background migration' - include_examples 'retained tracked jobs', 'succeeded' - end + context 'when a specific coordinator is given' do + let(:coordinator) { Gitlab::BackgroundMigration::JobCoordinator.for_tracking_database('main') } - context 'during retry race condition' do - let(:queue_items_added) { [] } - let!(:job_perform_method) do - ->(*arguments) do - Gitlab::Database::BackgroundMigrationJob.mark_all_as_succeeded( - RSpec.current_example.example_group_instance.job_class_name, - arguments - ) - - # Mock another process pushing queue jobs. - queue_items_added = RSpec.current_example.example_group_instance.queue_items_added - if queue_items_added.count < 10 - Sidekiq::Testing.disable! do - job_class_name = RSpec.current_example.example_group_instance.job_class_name - queue_items_added << BackgroundMigrationWorker.perform_async(job_class_name, [Time.current]) - queue_items_added << BackgroundMigrationWorker.perform_in(10, job_class_name, [Time.current]) - end - end + it 'uses that coordinator' do + expect(coordinator).to receive(:perform_in).with(10.minutes, 'Class', 'Hello', 'World').and_call_original + expect(worker_class).to receive(:perform_in).with(10.minutes, 'Class', 'Hello', 'World') + + model.migrate_in(10.minutes, 'Class', 'Hello', 'World', coordinator: coordinator) end end + end - it_behaves_like 'finalized tracked background migration' do - before do - model.finalize_background_migration(job_class_name, delete_tracking_jobs: ['succeeded']) + describe '#delete_queued_jobs' do + let(:job1) { double } + let(:job2) { double } + + it 'deletes all queued jobs for the given background migration' do + expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| + expect(coordinator).to receive(:steal).with('BackgroundMigrationClassName') do |&block| + expect(block.call(job1)).to be(false) + expect(block.call(job2)).to be(false) + end end + + expect(job1).to receive(:delete) + expect(job2).to receive(:delete) + + model.delete_queued_jobs('BackgroundMigrationClassName') end end end + context 'when the migration is running against the main database' do + it_behaves_like 'helpers that enqueue background migrations', BackgroundMigrationWorker, 'main' + end + describe '#delete_job_tracking' do let!(:job_class_name) { 'TestJob' } diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb index 4616bd6941e..7dc965c84fa 100644 --- a/spec/lib/gitlab/database/migrations/runner_spec.rb +++ b/spec/lib/gitlab/database/migrations/runner_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Gitlab::Database::Migrations::Runner do allow(ActiveRecord::Migrator).to receive(:new) do |dir, _all_migrations, _schema_migration_class, version_to_migrate| migrator = double(ActiveRecord::Migrator) expect(migrator).to receive(:run) do - migration_runs << OpenStruct.new(dir: dir, version_to_migrate: version_to_migrate) + migration_runs << double('migrator', dir: dir, version_to_migrate: version_to_migrate) end migrator end diff --git a/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb new file mode 100644 index 00000000000..e5a8143fcc3 --- /dev/null +++ b/spec/lib/gitlab/database/no_cross_db_foreign_keys_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'cross-database foreign keys' do + # TODO: We are trying to empty out this list in + # https://gitlab.com/groups/gitlab-org/-/epics/7249 . Once we are done we can + # keep this test and assert that there are no cross-db foreign keys. We + # should not be adding anything to this list but should instead only add new + # loose foreign keys + # https://docs.gitlab.com/ee/development/database/loose_foreign_keys.html . + let(:allowed_cross_database_foreign_keys) do + %w( + ci_build_report_results.project_id + ci_builds.project_id + ci_builds_metadata.project_id + ci_daily_build_group_report_results.group_id + ci_daily_build_group_report_results.project_id + ci_freeze_periods.project_id + ci_job_artifacts.project_id + ci_job_token_project_scope_links.added_by_id + ci_job_token_project_scope_links.source_project_id + ci_job_token_project_scope_links.target_project_id + ci_pending_builds.namespace_id + ci_pending_builds.project_id + ci_pipeline_schedules.owner_id + ci_pipeline_schedules.project_id + ci_pipelines.merge_request_id + ci_pipelines.project_id + ci_project_monthly_usages.project_id + ci_refs.project_id + ci_resource_groups.project_id + ci_runner_namespaces.namespace_id + ci_runner_projects.project_id + ci_running_builds.project_id + ci_sources_pipelines.project_id + ci_sources_pipelines.source_project_id + ci_sources_projects.source_project_id + ci_stages.project_id + ci_subscriptions_projects.downstream_project_id + ci_subscriptions_projects.upstream_project_id + ci_triggers.owner_id + ci_triggers.project_id + ci_unit_tests.project_id + ci_variables.project_id + dast_profiles_pipelines.ci_pipeline_id + dast_scanner_profiles_builds.ci_build_id + dast_site_profiles_builds.ci_build_id + dast_site_profiles_pipelines.ci_pipeline_id + external_pull_requests.project_id + merge_requests.head_pipeline_id + merge_trains.pipeline_id + requirements_management_test_reports.build_id + security_scans.build_id + vulnerability_feedback.pipeline_id + vulnerability_occurrence_pipelines.pipeline_id + vulnerability_statistics.latest_pipeline_id + ).freeze + end + + def foreign_keys_for(table_name) + ApplicationRecord.connection.foreign_keys(table_name) + end + + def is_cross_db?(fk_record) + Gitlab::Database::GitlabSchema.table_schemas([fk_record.from_table, fk_record.to_table]).many? + end + + it 'onlies have allowed list of cross-database foreign keys', :aggregate_failures do + all_tables = ApplicationRecord.connection.data_sources + + all_tables.each do |table| + foreign_keys_for(table).each do |fk| + if is_cross_db?(fk) + column = "#{fk.from_table}.#{fk.column}" + expect(allowed_cross_database_foreign_keys).to include(column), "Found extra cross-database foreign key #{column} referencing #{fk.to_table} with constraint name #{fk.name}. When a foreign key references another database you must use a Loose Foreign Key instead https://docs.gitlab.com/ee/development/database/loose_foreign_keys.html ." + end + end + end + end +end diff --git a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb index 5e107109fc9..64dcdb9628a 100644 --- a/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb +++ b/spec/lib/gitlab/database/partitioning/partition_manager_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do let(:model) { double(partitioning_strategy: partitioning_strategy, table_name: table, connection: connection) } let(:partitioning_strategy) { double(missing_partitions: partitions, extra_partitions: [], after_adding_partitions: nil) } let(:connection) { ActiveRecord::Base.connection } - let(:table) { "some_table" } + let(:table) { "issues" } before do allow(connection).to receive(:table_exists?).and_call_original @@ -36,6 +36,7 @@ RSpec.describe Gitlab::Database::Partitioning::PartitionManager do end it 'creates the partition' do + expect(connection).to receive(:execute).with("LOCK TABLE \"#{table}\" IN ACCESS EXCLUSIVE MODE") expect(connection).to receive(:execute).with(partitions.first.to_sql) expect(connection).to receive(:execute).with(partitions.second.to_sql) diff --git a/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb b/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb index 636a09e5710..1cec0463055 100644 --- a/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb +++ b/spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Database::Partitioning::SlidingListStrategy do let(:connection) { ActiveRecord::Base.connection } let(:table_name) { :_test_partitioned_test } - let(:model) { double('model', table_name: table_name, ignored_columns: %w[partition]) } + let(:model) { double('model', table_name: table_name, ignored_columns: %w[partition], connection: connection) } let(:next_partition_if) { double('next_partition_if') } let(:detach_partition_if) { double('detach_partition_if') } @@ -94,7 +94,8 @@ RSpec.describe Gitlab::Database::Partitioning::SlidingListStrategy do let(:detach_partition_if) { ->(p) { p != 5 } } it 'is the leading set of partitions before that value' do - expect(strategy.extra_partitions.map(&:value)).to contain_exactly(1, 2, 3, 4) + # should not contain partition 2 since it's the default value for the partition column + expect(strategy.extra_partitions.map(&:value)).to contain_exactly(1, 3, 4) end end @@ -102,7 +103,7 @@ RSpec.describe Gitlab::Database::Partitioning::SlidingListStrategy do let(:detach_partition_if) { proc { true } } it 'is all but the most recent partition', :aggregate_failures do - expect(strategy.extra_partitions.map(&:value)).to contain_exactly(1, 2, 3, 4, 5, 6, 7, 8, 9) + expect(strategy.extra_partitions.map(&:value)).to contain_exactly(1, 3, 4, 5, 6, 7, 8, 9) expect(strategy.current_partitions.map(&:value).max).to eq(10) end diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb index c43b51e10a0..3072c413246 100644 --- a/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb +++ b/spec/lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table_spec.rb @@ -3,14 +3,15 @@ require 'spec_helper' RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartitionedTable, '#perform' do - subject { described_class.new } + subject(:backfill_job) { described_class.new(connection: connection) } + let(:connection) { ActiveRecord::Base.connection } let(:source_table) { '_test_partitioning_backfills' } let(:destination_table) { "#{source_table}_part" } let(:unique_key) { 'id' } before do - allow(subject).to receive(:transaction_open?).and_return(false) + allow(backfill_job).to receive(:transaction_open?).and_return(false) end context 'when the destination table exists' do @@ -50,10 +51,9 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition stub_const("#{described_class}::SUB_BATCH_SIZE", 2) stub_const("#{described_class}::PAUSE_SECONDS", pause_seconds) - allow(subject).to receive(:sleep) + allow(backfill_job).to receive(:sleep) end - let(:connection) { ActiveRecord::Base.connection } let(:source_model) { Class.new(ActiveRecord::Base) } let(:destination_model) { Class.new(ActiveRecord::Base) } let(:timestamp) { Time.utc(2020, 1, 2).round } @@ -66,7 +66,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition it 'copies data into the destination table idempotently' do expect(destination_model.count).to eq(0) - subject.perform(source1.id, source3.id, source_table, destination_table, unique_key) + backfill_job.perform(source1.id, source3.id, source_table, destination_table, unique_key) expect(destination_model.count).to eq(3) @@ -76,7 +76,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition expect(destination_record.attributes).to eq(source_record.attributes) end - subject.perform(source1.id, source3.id, source_table, destination_table, unique_key) + backfill_job.perform(source1.id, source3.id, source_table, destination_table, unique_key) expect(destination_model.count).to eq(3) end @@ -87,13 +87,13 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition expect(bulk_copy).to receive(:copy_between).with(source3.id, source3.id) end - subject.perform(source1.id, source3.id, source_table, destination_table, unique_key) + backfill_job.perform(source1.id, source3.id, source_table, destination_table, unique_key) end it 'pauses after copying each sub-batch' do - expect(subject).to receive(:sleep).with(pause_seconds).twice + expect(backfill_job).to receive(:sleep).with(pause_seconds).twice - subject.perform(source1.id, source3.id, source_table, destination_table, unique_key) + backfill_job.perform(source1.id, source3.id, source_table, destination_table, unique_key) end it 'marks each job record as succeeded after processing' do @@ -103,7 +103,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition expect(::Gitlab::Database::BackgroundMigrationJob).to receive(:mark_all_as_succeeded).and_call_original expect do - subject.perform(source1.id, source3.id, source_table, destination_table, unique_key) + backfill_job.perform(source1.id, source3.id, source_table, destination_table, unique_key) end.to change { ::Gitlab::Database::BackgroundMigrationJob.succeeded.count }.from(0).to(1) end @@ -111,24 +111,24 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition create(:background_migration_job, class_name: "::#{described_class.name}", arguments: [source1.id, source3.id, source_table, destination_table, unique_key]) - jobs_updated = subject.perform(source1.id, source3.id, source_table, destination_table, unique_key) + jobs_updated = backfill_job.perform(source1.id, source3.id, source_table, destination_table, unique_key) expect(jobs_updated).to eq(1) end context 'when the job is run within an explicit transaction block' do - let(:mock_connection) { double('connection') } + subject(:backfill_job) { described_class.new(connection: mock_connection) } - before do - allow(subject).to receive(:connection).and_return(mock_connection) - allow(subject).to receive(:transaction_open?).and_return(true) - end + let(:mock_connection) { double('connection') } it 'raises an error before copying data' do + expect(backfill_job).to receive(:transaction_open?).and_call_original + + expect(mock_connection).to receive(:transaction_open?).and_return(true) expect(mock_connection).not_to receive(:execute) expect do - subject.perform(1, 100, source_table, destination_table, unique_key) + backfill_job.perform(1, 100, source_table, destination_table, unique_key) end.to raise_error(/Aborting job to backfill partitioned #{source_table}/) expect(destination_model.count).to eq(0) @@ -137,24 +137,25 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::BackfillPartition end context 'when the destination table does not exist' do + subject(:backfill_job) { described_class.new(connection: mock_connection) } + let(:mock_connection) { double('connection') } let(:mock_logger) { double('logger') } before do - allow(subject).to receive(:connection).and_return(mock_connection) - allow(subject).to receive(:logger).and_return(mock_logger) - - expect(mock_connection).to receive(:table_exists?).with(destination_table).and_return(false) + allow(backfill_job).to receive(:logger).and_return(mock_logger) allow(mock_logger).to receive(:warn) end it 'exits without attempting to copy data' do + expect(mock_connection).to receive(:table_exists?).with(destination_table).and_return(false) expect(mock_connection).not_to receive(:execute) subject.perform(1, 100, source_table, destination_table, unique_key) end it 'logs a warning message that the job was skipped' do + expect(mock_connection).to receive(:table_exists?).with(destination_table).and_return(false) expect(mock_logger).to receive(:warn).with(/#{destination_table} does not exist/) subject.perform(1, 100, source_table, destination_table, unique_key) diff --git a/spec/lib/gitlab/database/reflection_spec.rb b/spec/lib/gitlab/database/reflection_spec.rb index 7c3d797817d..efc5bd1c1e1 100644 --- a/spec/lib/gitlab/database/reflection_spec.rb +++ b/spec/lib/gitlab/database/reflection_spec.rb @@ -259,6 +259,66 @@ RSpec.describe Gitlab::Database::Reflection do end end + describe '#flavor', :delete do + let(:result) { [double] } + let(:connection) { database.model.connection } + + def stub_statements(statements) + statements = Array.wrap(statements) + execute = connection.method(:execute) + + allow(connection).to receive(:execute) do |arg| + if statements.include?(arg) + result + else + execute.call(arg) + end + end + end + + it 're-raises exceptions not matching expected messages' do + expect(database.model.connection) + .to receive(:execute) + .and_raise(ActiveRecord::StatementInvalid, 'Something else') + + expect { database.flavor }.to raise_error ActiveRecord::StatementInvalid, /Something else/ + end + + it 'recognizes Amazon Aurora PostgreSQL' do + stub_statements(['SHOW rds.extensions', 'SELECT AURORA_VERSION()']) + + expect(database.flavor).to eq('Amazon Aurora PostgreSQL') + end + + it 'recognizes PostgreSQL on Amazon RDS' do + stub_statements('SHOW rds.extensions') + + expect(database.flavor).to eq('PostgreSQL on Amazon RDS') + end + + it 'recognizes CloudSQL for PostgreSQL' do + stub_statements('SHOW cloudsql.iam_authentication') + + expect(database.flavor).to eq('Cloud SQL for PostgreSQL') + end + + it 'recognizes Azure Database for PostgreSQL - Flexible Server' do + stub_statements(["SELECT datname FROM pg_database WHERE datname = 'azure_maintenance'", 'SHOW azure.extensions']) + + expect(database.flavor).to eq('Azure Database for PostgreSQL - Flexible Server') + end + + it 'recognizes Azure Database for PostgreSQL - Single Server' do + stub_statements("SELECT datname FROM pg_database WHERE datname = 'azure_maintenance'") + + expect(database.flavor).to eq('Azure Database for PostgreSQL - Single Server') + end + + it 'returns nil if can not recognize the flavor' do + expect(database.flavor).to be_nil + end + end + describe '#config' do it 'returns a HashWithIndifferentAccess' do expect(database.config) diff --git a/spec/lib/gitlab/database/reindexing/coordinator_spec.rb b/spec/lib/gitlab/database/reindexing/coordinator_spec.rb index 0afbe46b7f1..bb91617714a 100644 --- a/spec/lib/gitlab/database/reindexing/coordinator_spec.rb +++ b/spec/lib/gitlab/database/reindexing/coordinator_spec.rb @@ -6,30 +6,34 @@ RSpec.describe Gitlab::Database::Reindexing::Coordinator do include Database::DatabaseHelpers include ExclusiveLeaseHelpers - describe '.perform' do - subject { described_class.new(index, notifier).perform } - - let(:index) { create(:postgres_index) } - let(:notifier) { instance_double(Gitlab::Database::Reindexing::GrafanaNotifier, notify_start: nil, notify_end: nil) } - let(:reindexer) { instance_double(Gitlab::Database::Reindexing::ReindexConcurrently, perform: nil) } - let(:action) { create(:reindex_action, index: index) } + let(:notifier) { instance_double(Gitlab::Database::Reindexing::GrafanaNotifier, notify_start: nil, notify_end: nil) } + let(:index) { create(:postgres_index) } + let(:connection) { index.connection } - let!(:lease) { stub_exclusive_lease(lease_key, uuid, timeout: lease_timeout) } - let(:lease_key) { "gitlab/database/reindexing/coordinator/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" } - let(:lease_timeout) { 1.day } - let(:uuid) { 'uuid' } + let!(:lease) { stub_exclusive_lease(lease_key, uuid, timeout: lease_timeout) } + let(:lease_key) { "gitlab/database/reindexing/coordinator/#{Gitlab::Database::PRIMARY_DATABASE_NAME}" } + let(:lease_timeout) { 1.day } + let(:uuid) { 'uuid' } - around do |example| - model = Gitlab::Database.database_base_models[Gitlab::Database::PRIMARY_DATABASE_NAME] + around do |example| + model = Gitlab::Database.database_base_models[Gitlab::Database::PRIMARY_DATABASE_NAME] - Gitlab::Database::SharedModel.using_connection(model.connection) do - example.run - end + Gitlab::Database::SharedModel.using_connection(model.connection) do + example.run end + end - before do - swapout_view_for_table(:postgres_indexes) + before do + swapout_view_for_table(:postgres_indexes) + end + describe '#perform' do + subject { described_class.new(index, notifier).perform } + + let(:reindexer) { instance_double(Gitlab::Database::Reindexing::ReindexConcurrently, perform: nil) } + let(:action) { create(:reindex_action, index: index) } + + before do allow(Gitlab::Database::Reindexing::ReindexConcurrently).to receive(:new).with(index).and_return(reindexer) allow(Gitlab::Database::Reindexing::ReindexAction).to receive(:create_for).with(index).and_return(action) end @@ -87,4 +91,40 @@ RSpec.describe Gitlab::Database::Reindexing::Coordinator do end end end + + describe '#drop' do + let(:connection) { index.connection } + + subject(:drop) { described_class.new(index, notifier).drop } + + context 'when exclusive lease is granted' do + it 'drops the index with lock retries' do + expect(lease).to receive(:try_obtain).ordered.and_return(uuid) + + expect_query("SET lock_timeout TO '60000ms'") + expect_query("DROP INDEX CONCURRENTLY IF EXISTS \"public\".\"#{index.name}\"") + expect_query("RESET idle_in_transaction_session_timeout; RESET lock_timeout") + + expect(Gitlab::ExclusiveLease).to receive(:cancel).ordered.with(lease_key, uuid) + + drop + end + + def expect_query(sql) + expect(connection).to receive(:execute).ordered.with(sql).and_wrap_original do |method, sql| + method.call(sql.sub(/CONCURRENTLY/, '')) + end + end + end + + context 'when exclusive lease is not granted' do + it 'does not drop the index' do + expect(lease).to receive(:try_obtain).ordered.and_return(false) + expect(Gitlab::Database::WithLockRetriesOutsideTransaction).not_to receive(:new) + expect(connection).not_to receive(:execute) + + drop + end + end + end end diff --git a/spec/lib/gitlab/email/failure_handler_spec.rb b/spec/lib/gitlab/email/failure_handler_spec.rb new file mode 100644 index 00000000000..a912996e8f2 --- /dev/null +++ b/spec/lib/gitlab/email/failure_handler_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Email::FailureHandler do + let(:raw_message) { fixture_file('emails/valid_reply.eml') } + let(:receiver) { Gitlab::Email::Receiver.new(raw_message) } + + context 'email processing errors' do + where(:error, :message, :can_retry) do + [ + [Gitlab::Email::UnknownIncomingEmail, "We couldn't figure out what the email is for", false], + [Gitlab::Email::SentNotificationNotFoundError, "We couldn't figure out what the email is in reply to", false], + [Gitlab::Email::ProjectNotFound, "We couldn't find the project", false], + [Gitlab::Email::EmptyEmailError, "It appears that the email is blank", true], + [Gitlab::Email::UserNotFoundError, "We couldn't figure out what user corresponds to the email", false], + [Gitlab::Email::UserBlockedError, "Your account has been blocked", false], + [Gitlab::Email::UserNotAuthorizedError, "You are not allowed to perform this action", false], + [Gitlab::Email::NoteableNotFoundError, "The thread you are replying to no longer exists", false], + [Gitlab::Email::InvalidAttachment, "Could not deal with that", false], + [Gitlab::Email::InvalidRecordError, "The note could not be created for the following reasons", true], + [Gitlab::Email::EmailTooLarge, "it is too large", false] + ] + end + + with_them do + it "sends out a rejection email for #{params[:error]}" do + perform_enqueued_jobs do + described_class.handle(receiver, error.new(message)) + end + + email = ActionMailer::Base.deliveries.last + expect(email).not_to be_nil + expect(email.to).to match_array(["jake@adventuretime.ooo"]) + expect(email.subject).to include("Rejected") + expect(email.body.parts.last.to_s).to include(message) + end + + it 'strips out the body before passing to EmailRejectionMailer' do + mail = Mail.new(raw_message) + mail.body = nil + + expect(EmailRejectionMailer).to receive(:rejection).with(match(message), mail.encoded, can_retry).and_call_original + + described_class.handle(receiver, error.new(message)) + end + end + end + + context 'non-processing errors' do + where(:error) do + [ + [Gitlab::Email::AutoGeneratedEmailError.new("")], + [ActiveRecord::StatementTimeout.new("StatementTimeout")], + [RateLimitedService::RateLimitedError.new(key: :issues_create, rate_limiter: nil)] + ] + end + + with_them do + it "does not send a rejection email for #{params[:error]}" do + perform_enqueued_jobs do + described_class.handle(receiver, error) + end + + expect(ActionMailer::Base.deliveries).to be_empty + end + end + end +end diff --git a/spec/lib/gitlab/error_tracking/processor/sidekiq_processor_spec.rb b/spec/lib/gitlab/error_tracking/processor/sidekiq_processor_spec.rb index af5f11c9362..3febc10831a 100644 --- a/spec/lib/gitlab/error_tracking/processor/sidekiq_processor_spec.rb +++ b/spec/lib/gitlab/error_tracking/processor/sidekiq_processor_spec.rb @@ -178,5 +178,14 @@ RSpec.describe Gitlab::ErrorTracking::Processor::SidekiqProcessor do expect(result_hash.dig(:extra, :sidekiq)).to be_nil end end + + context 'when there is Sidekiq data but no job' do + let(:value) { { other: 'foo' } } + let(:wrapped_value) { { extra: { sidekiq: value } } } + + it 'does nothing' do + expect(result_hash.dig(:extra, :sidekiq)).to eq(value) + end + end end end diff --git a/spec/lib/gitlab/event_store/event_spec.rb b/spec/lib/gitlab/event_store/event_spec.rb new file mode 100644 index 00000000000..97f6870a5ec --- /dev/null +++ b/spec/lib/gitlab/event_store/event_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::EventStore::Event do + let(:event_class) { stub_const('TestEvent', Class.new(described_class)) } + let(:event) { event_class.new(data: data) } + let(:data) { { project_id: 123, project_path: 'org/the-project' } } + + context 'when schema is not defined' do + it 'raises an error on initialization' do + expect { event }.to raise_error(NotImplementedError) + end + end + + context 'when schema is defined' do + before do + event_class.class_eval do + def schema + { + 'required' => ['project_id'], + 'type' => 'object', + 'properties' => { + 'project_id' => { 'type' => 'integer' }, + 'project_path' => { 'type' => 'string' } + } + } + end + end + end + + describe 'schema validation' do + context 'when data matches the schema' do + it 'initializes the event correctly' do + expect(event.data).to eq(data) + end + end + + context 'when required properties are present as well as unknown properties' do + let(:data) { { project_id: 123, unknown_key: 'unknown_value' } } + + it 'initializes the event correctly' do + expect(event.data).to eq(data) + end + end + + context 'when some properties are missing' do + let(:data) { { project_path: 'org/the-project' } } + + it 'expects all properties to be present' do + expect { event }.to raise_error(Gitlab::EventStore::InvalidEvent, /does not match the defined schema/) + end + end + + context 'when data is not a Hash' do + let(:data) { 123 } + + it 'raises an error' do + expect { event }.to raise_error(Gitlab::EventStore::InvalidEvent, 'Event data must be a Hash') + end + end + end + end +end diff --git a/spec/lib/gitlab/event_store/store_spec.rb b/spec/lib/gitlab/event_store/store_spec.rb new file mode 100644 index 00000000000..711e1d5b4d5 --- /dev/null +++ b/spec/lib/gitlab/event_store/store_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::EventStore::Store do + let(:event_klass) { stub_const('TestEvent', Class.new(Gitlab::EventStore::Event)) } + let(:event) { event_klass.new(data: data) } + let(:another_event_klass) { stub_const('TestAnotherEvent', Class.new(Gitlab::EventStore::Event)) } + + let(:worker) do + stub_const('EventSubscriber', Class.new).tap do |klass| + klass.class_eval do + include ApplicationWorker + include Gitlab::EventStore::Subscriber + + def handle_event(event) + event.data + end + end + end + end + + let(:another_worker) do + stub_const('AnotherEventSubscriber', Class.new).tap do |klass| + klass.class_eval do + include ApplicationWorker + include Gitlab::EventStore::Subscriber + end + end + end + + let(:unrelated_worker) do + stub_const('UnrelatedEventSubscriber', Class.new).tap do |klass| + klass.class_eval do + include ApplicationWorker + include Gitlab::EventStore::Subscriber + end + end + end + + before do + event_klass.class_eval do + def schema + { + 'required' => %w[name id], + 'type' => 'object', + 'properties' => { + 'name' => { 'type' => 'string' }, + 'id' => { 'type' => 'integer' } + } + } + end + end + end + + describe '#subscribe' do + it 'subscribes a worker to an event' do + store = described_class.new do |s| + s.subscribe worker, to: event_klass + end + + subscriptions = store.subscriptions[event_klass] + expect(subscriptions.map(&:worker)).to contain_exactly(worker) + end + + it 'subscribes multiple workers to an event' do + store = described_class.new do |s| + s.subscribe worker, to: event_klass + s.subscribe another_worker, to: event_klass + end + + subscriptions = store.subscriptions[event_klass] + expect(subscriptions.map(&:worker)).to contain_exactly(worker, another_worker) + end + + it 'subscribes a worker to multiple events is separate calls' do + store = described_class.new do |s| + s.subscribe worker, to: event_klass + s.subscribe worker, to: another_event_klass + end + + subscriptions = store.subscriptions[event_klass] + expect(subscriptions.map(&:worker)).to contain_exactly(worker) + + subscriptions = store.subscriptions[another_event_klass] + expect(subscriptions.map(&:worker)).to contain_exactly(worker) + end + + it 'subscribes a worker to multiple events in a single call' do + store = described_class.new do |s| + s.subscribe worker, to: [event_klass, another_event_klass] + end + + subscriptions = store.subscriptions[event_klass] + expect(subscriptions.map(&:worker)).to contain_exactly(worker) + + subscriptions = store.subscriptions[another_event_klass] + expect(subscriptions.map(&:worker)).to contain_exactly(worker) + end + + it 'subscribes a worker to an event with condition' do + store = described_class.new do |s| + s.subscribe worker, to: event_klass, if: ->(event) { event.data[:name] == 'Alice' } + end + + subscriptions = store.subscriptions[event_klass] + + expect(subscriptions.size).to eq(1) + + subscription = subscriptions.first + expect(subscription).to be_an_instance_of(Gitlab::EventStore::Subscription) + expect(subscription.worker).to eq(worker) + expect(subscription.condition.call(double(data: { name: 'Bob' }))).to eq(false) + expect(subscription.condition.call(double(data: { name: 'Alice' }))).to eq(true) + end + + it 'refuses the subscription if the target is not an Event object' do + expect do + described_class.new do |s| + s.subscribe worker, to: Integer + end + end.to raise_error( + Gitlab::EventStore::Error, + /Event being subscribed to is not a subclass of Gitlab::EventStore::Event/) + end + + it 'refuses the subscription if the subscriber is not a worker' do + expect do + described_class.new do |s| + s.subscribe double, to: event_klass + end + end.to raise_error( + Gitlab::EventStore::Error, + /Subscriber is not an ApplicationWorker/) + end + end + + describe '#publish' do + let(:data) { { name: 'Bob', id: 123 } } + + context 'when event has a subscribed worker' do + let(:store) do + described_class.new do |store| + store.subscribe worker, to: event_klass + store.subscribe another_worker, to: another_event_klass + end + end + + it 'dispatches the event to the subscribed worker' do + expect(worker).to receive(:perform_async).with('TestEvent', data) + expect(another_worker).not_to receive(:perform_async) + + store.publish(event) + end + + context 'when other workers subscribe to the same event' do + let(:store) do + described_class.new do |store| + store.subscribe worker, to: event_klass + store.subscribe another_worker, to: event_klass + store.subscribe unrelated_worker, to: another_event_klass + end + end + + it 'dispatches the event to each subscribed worker' do + expect(worker).to receive(:perform_async).with('TestEvent', data) + expect(another_worker).to receive(:perform_async).with('TestEvent', data) + expect(unrelated_worker).not_to receive(:perform_async) + + store.publish(event) + end + end + + context 'when an error is raised' do + before do + allow(worker).to receive(:perform_async).and_raise(NoMethodError, 'the error message') + end + + it 'is rescued and tracked' do + expect(Gitlab::ErrorTracking) + .to receive(:track_and_raise_for_dev_exception) + .with(kind_of(NoMethodError), event_class: event.class.name, event_data: event.data) + .and_call_original + + expect { store.publish(event) }.to raise_error(NoMethodError, 'the error message') + end + end + + it 'raises and tracks an error when event is published inside a database transaction' do + expect(Gitlab::ErrorTracking) + .to receive(:track_and_raise_for_dev_exception) + .at_least(:once) + .and_call_original + + expect do + ApplicationRecord.transaction do + store.publish(event) + end + end.to raise_error(Sidekiq::Worker::EnqueueFromTransactionError) + end + + it 'refuses publishing if the target is not an Event object' do + expect { store.publish(double(:event)) } + .to raise_error( + Gitlab::EventStore::Error, + /Event being published is not an instance of Gitlab::EventStore::Event/) + end + end + + context 'when event has subscribed workers with condition' do + let(:store) do + described_class.new do |s| + s.subscribe worker, to: event_klass, if: -> (event) { event.data[:name] == 'Bob' } + s.subscribe another_worker, to: event_klass, if: -> (event) { event.data[:name] == 'Alice' } + end + end + + let(:event) { event_klass.new(data: data) } + + it 'dispatches the event to the workers satisfying the condition' do + expect(worker).to receive(:perform_async).with('TestEvent', data) + expect(another_worker).not_to receive(:perform_async) + + store.publish(event) + end + end + end + + describe 'subscriber' do + let(:data) { { name: 'Bob', id: 123 } } + let(:event_name) { event.class.name } + let(:worker_instance) { worker.new } + + subject { worker_instance.perform(event_name, data) } + + it 'handles the event' do + expect(worker_instance).to receive(:handle_event).with(instance_of(event.class)) + + expect_any_instance_of(event.class) do |event| + expect(event).to receive(:data).and_return(data) + end + + subject + end + + context 'when the event name does not exist' do + let(:event_name) { 'UnknownClass' } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::EventStore::InvalidEvent) + end + end + + context 'when the worker does not define handle_event method' do + let(:worker_instance) { another_worker.new } + + it 'raises an error' do + expect { subject }.to raise_error(NotImplementedError) + end + end + end +end diff --git a/spec/lib/gitlab/exceptions_app_spec.rb b/spec/lib/gitlab/exceptions_app_spec.rb new file mode 100644 index 00000000000..6b726a044a8 --- /dev/null +++ b/spec/lib/gitlab/exceptions_app_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::ExceptionsApp, type: :request do + describe '.call' do + let(:exceptions_app) { described_class.new(Rails.public_path) } + let(:app) { ActionDispatch::ShowExceptions.new(error_raiser, exceptions_app) } + + before do + @app = app + end + + context 'for a 500 error' do + let(:error_raiser) { proc { raise 'an unhandled error' } } + + context 'for an HTML request' do + it 'fills in the request ID' do + get '/', env: { 'action_dispatch.request_id' => 'foo' } + + expect(response).to have_gitlab_http_status(:internal_server_error) + expect(response).to have_header('X-Gitlab-Custom-Error') + expect(response.body).to include('Request ID: <code>foo</code>') + end + + it 'HTML-escapes the request ID' do + get '/', env: { 'action_dispatch.request_id' => '<b>foo</b>' } + + expect(response).to have_gitlab_http_status(:internal_server_error) + expect(response).to have_header('X-Gitlab-Custom-Error') + expect(response.body).to include('Request ID: <code><b>foo</b></code>') + end + + it 'returns an empty 500 when the 500.html page cannot be found' do + allow(File).to receive(:exist?).and_return(false) + + get '/', env: { 'action_dispatch.request_id' => 'foo' } + + expect(response).to have_gitlab_http_status(:internal_server_error) + expect(response).not_to have_header('X-Gitlab-Custom-Error') + expect(response.body).to be_empty + end + end + + context 'for a JSON request' do + it 'does not include the request ID' do + get '/', env: { 'action_dispatch.request_id' => 'foo' }, as: :json + + expect(response).to have_gitlab_http_status(:internal_server_error) + expect(response).not_to have_header('X-Gitlab-Custom-Error') + expect(response.body).not_to include('foo') + end + end + end + + context 'for a 404 error' do + let(:error_raiser) { proc { raise AbstractController::ActionNotFound } } + + it 'returns a 404 response that does not include the request ID' do + get '/', env: { 'action_dispatch.request_id' => 'foo' } + + expect(response).to have_gitlab_http_status(:not_found) + expect(response).not_to have_header('X-Gitlab-Custom-Error') + expect(response.body).not_to include('foo') + end + end + end +end diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb index f4875aa0ebc..7d4a3655be6 100644 --- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb @@ -92,7 +92,7 @@ RSpec.describe Gitlab::Gfm::ReferenceRewriter do let!(:group_label) { create(:group_label, id: 321, name: 'group label', group: old_group) } before do - old_project.update(namespace: old_group) + old_project.update!(namespace: old_group) end context 'label referenced by id' do diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index bf2e3c7f5f8..4bf7994f4dd 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -96,7 +96,7 @@ RSpec.describe Gitlab::GitAccess do context 'when the DeployKey has access to the project' do before do - deploy_key.deploy_keys_projects.create(project: project, can_push: true) + deploy_key.deploy_keys_projects.create!(project: project, can_push: true) end it 'allows push and pull access' do @@ -820,7 +820,7 @@ RSpec.describe Gitlab::GitAccess do project.add_role(user, role) end - protected_branch.save + protected_branch.save! aggregate_failures do matrix.each do |action, allowed| @@ -1090,7 +1090,7 @@ RSpec.describe Gitlab::GitAccess do context 'when deploy_key can push' do context 'when project is authorized' do before do - key.deploy_keys_projects.create(project: project, can_push: true) + key.deploy_keys_projects.create!(project: project, can_push: true) end it { expect { push_access_check }.not_to raise_error } @@ -1120,7 +1120,7 @@ RSpec.describe Gitlab::GitAccess do context 'when deploy_key cannot push' do context 'when project is authorized' do before do - key.deploy_keys_projects.create(project: project, can_push: false) + key.deploy_keys_projects.create!(project: project, can_push: false) end it { expect { push_access_check }.to raise_forbidden(described_class::ERROR_MESSAGES[:deploy_key_upload]) } diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index 20d5972bd88..9c399e78d80 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -233,30 +233,6 @@ RSpec.describe Gitlab::Gpg::Commit do verification_status: 'multiple_signatures' ) end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(multiple_gpg_signatures: false) - end - - it 'returns an valid signature' do - verified_signature = double('verified-signature', fingerprint: GpgHelpers::User1.fingerprint, valid?: true) - allow(GPGME::Crypto).to receive(:new).and_return(crypto) - allow(crypto).to receive(:verify).and_yield(verified_signature).and_yield(verified_signature) - - signature = described_class.new(commit).signature - - expect(signature).to have_attributes( - commit_sha: commit_sha, - project: project, - gpg_key: gpg_key, - gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - gpg_key_user_name: GpgHelpers::User1.names.first, - gpg_key_user_email: GpgHelpers::User1.emails.first, - verification_status: 'verified' - ) - end - end end context 'commit signed with a subkey' do diff --git a/spec/lib/gitlab/http_spec.rb b/spec/lib/gitlab/http_spec.rb index d0aae2ac475..7d459f2d88a 100644 --- a/spec/lib/gitlab/http_spec.rb +++ b/spec/lib/gitlab/http_spec.rb @@ -29,14 +29,42 @@ RSpec.describe Gitlab::HTTP do context 'when reading the response is too slow' do before do + # Override Net::HTTP to add a delay between sending each response chunk + mocked_http = Class.new(Net::HTTP) do + def request(*) + super do |response| + response.instance_eval do + def read_body(*) + @body.each do |fragment| + sleep 0.002.seconds + + yield fragment if block_given? + end + end + end + + yield response if block_given? + + response + end + end + end + + @original_net_http = Net.send(:remove_const, :HTTP) + Net.send(:const_set, :HTTP, mocked_http) + stub_const("#{described_class}::DEFAULT_READ_TOTAL_TIMEOUT", 0.001.seconds) WebMock.stub_request(:post, /.*/).to_return do |request| - sleep 0.002.seconds - { body: 'I\'m slow', status: 200 } + { body: %w(a b), status: 200 } end end + after do + Net.send(:remove_const, :HTTP) + Net.send(:const_set, :HTTP, @original_net_http) + end + let(:options) { {} } subject(:request_slow_responder) { described_class.post('http://example.org', **options) } @@ -51,7 +79,7 @@ RSpec.describe Gitlab::HTTP do end it 'still calls the block' do - expect { |b| described_class.post('http://example.org', **options, &b) }.to yield_with_args + expect { |b| described_class.post('http://example.org', **options, &b) }.to yield_successive_args('a', 'b') end end diff --git a/spec/lib/gitlab/import/set_async_jid_spec.rb b/spec/lib/gitlab/import/set_async_jid_spec.rb index 016f7cac61a..6931a7a953d 100644 --- a/spec/lib/gitlab/import/set_async_jid_spec.rb +++ b/spec/lib/gitlab/import/set_async_jid_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::Import::SetAsyncJid do it 'sets the JID in Redis' do expect(Gitlab::SidekiqStatus) .to receive(:set) - .with("async-import/project-import-state/#{project.id}", Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION, value: 2) + .with("async-import/project-import-state/#{project.id}", Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION) .and_call_original described_class.set_jid(project.import_state) diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 7ed80cbcf66..f4a112d35aa 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -58,6 +58,7 @@ issues: - test_reports - requirement - incident_management_issuable_escalation_status +- incident_management_timeline_events - pending_escalations - customer_relations_contacts - issue_customer_relations_contacts @@ -135,6 +136,7 @@ project_members: - source - project - member_task +- member_namespace merge_requests: - status_check_responses - subscriptions @@ -280,6 +282,7 @@ ci_pipelines: - dast_site_profiles_pipeline - package_build_infos - package_file_build_infos +- build_trace_chunks ci_refs: - project - ci_pipelines @@ -601,6 +604,7 @@ project: - bulk_import_exports - ci_project_mirror - sync_events +- secure_files award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/avatar_saver_spec.rb b/spec/lib/gitlab/import_export/avatar_saver_spec.rb index 334d930c47c..d897ce76da0 100644 --- a/spec/lib/gitlab/import_export/avatar_saver_spec.rb +++ b/spec/lib/gitlab/import_export/avatar_saver_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Gitlab::ImportExport::AvatarSaver do end it 'saves a project avatar' do - described_class.new(project: project_with_avatar, shared: shared).save + described_class.new(project: project_with_avatar, shared: shared).save # rubocop:disable Rails/SaveBang expect(File).to exist(Dir["#{shared.export_path}/avatar/**/dk.png"].first) end diff --git a/spec/lib/gitlab/import_export/base/relation_factory_spec.rb b/spec/lib/gitlab/import_export/base/relation_factory_spec.rb index bd8873fe20e..b8999f608b1 100644 --- a/spec/lib/gitlab/import_export/base/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/base/relation_factory_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Gitlab::ImportExport::Base::RelationFactory do let(:excluded_keys) { [] } subject do - described_class.create(relation_sym: relation_sym, + described_class.create(relation_sym: relation_sym, # rubocop:disable Rails/SaveBang relation_hash: relation_hash, relation_index: 1, object_builder: Gitlab::ImportExport::Project::ObjectBuilder, diff --git a/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb b/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb index 6680f4e7a03..346f653acd4 100644 --- a/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/design_repo_restorer_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Gitlab::ImportExport::DesignRepoRestorer do allow(instance).to receive(:storage_path).and_return(export_path) end - bundler.save + bundler.save # rubocop:disable Rails/SaveBang end after do diff --git a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb index d5f31f235f5..adb613c3abc 100644 --- a/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/fast_hash_serializer_spec.rb @@ -258,7 +258,7 @@ RSpec.describe Gitlab::ImportExport::FastHashSerializer do create(:resource_label_event, label: group_label, merge_request: merge_request) create(:event, :created, target: milestone, project: project, author: user) - create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker', properties: { one: 'value' }) + create(:integration, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker', properties: { one: 'value' }) create(:project_custom_attribute, project: project) create(:project_custom_attribute, project: project) diff --git a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb index ce6607f6a26..2f1e2dd2db4 100644 --- a/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/group/relation_tree_restorer_spec.rb @@ -48,41 +48,16 @@ RSpec.describe Gitlab::ImportExport::Group::RelationTreeRestorer do subject { relation_tree_restorer.restore } - shared_examples 'logging of relations creation' do - context 'when log_import_export_relation_creation feature flag is enabled' do - before do - stub_feature_flags(log_import_export_relation_creation: group) - end - - it 'logs top-level relation creation' do - expect(shared.logger) - .to receive(:info) - .with(hash_including(message: '[Project/Group Import] Created new object relation')) - .at_least(:once) - - subject - end - end - - context 'when log_import_export_relation_creation feature flag is disabled' do - before do - stub_feature_flags(log_import_export_relation_creation: false) - end - - it 'does not log top-level relation creation' do - expect(shared.logger) - .to receive(:info) - .with(hash_including(message: '[Project/Group Import] Created new object relation')) - .never - - subject - end - end - end - it 'restores group tree' do expect(subject).to eq(true) end - include_examples 'logging of relations creation' + it 'logs top-level relation creation' do + expect(shared.logger) + .to receive(:info) + .with(hash_including(message: '[Project/Group Import] Created new object relation')) + .at_least(:once) + + subject + end end diff --git a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb index 80ba50976af..ea8b10675af 100644 --- a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb @@ -88,7 +88,7 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory, :use_clean_rails_ end context 'original service exists' do - let(:service_id) { create(:service, project: project).id } + let(:service_id) { create(:integration, project: project).id } it 'does not have the original service_id' do expect(created_object.service_id).not_to eq(service_id) diff --git a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb index 577f1e46db6..b7b652005e9 100644 --- a/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project/relation_tree_restorer_spec.rb @@ -54,38 +54,6 @@ RSpec.describe Gitlab::ImportExport::Project::RelationTreeRestorer do end end - shared_examples 'logging of relations creation' do - context 'when log_import_export_relation_creation feature flag is enabled' do - before do - stub_feature_flags(log_import_export_relation_creation: group) - end - - it 'logs top-level relation creation' do - expect(shared.logger) - .to receive(:info) - .with(hash_including(message: '[Project/Group Import] Created new object relation')) - .at_least(:once) - - subject - end - end - - context 'when log_import_export_relation_creation feature flag is disabled' do - before do - stub_feature_flags(log_import_export_relation_creation: false) - end - - it 'does not log top-level relation creation' do - expect(shared.logger) - .to receive(:info) - .with(hash_including(message: '[Project/Group Import] Created new object relation')) - .never - - subject - end - end - end - context 'with legacy reader' do let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' } let(:relation_reader) do @@ -106,7 +74,14 @@ RSpec.describe Gitlab::ImportExport::Project::RelationTreeRestorer do create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project', group: group) end - include_examples 'logging of relations creation' + it 'logs top-level relation creation' do + expect(shared.logger) + .to receive(:info) + .with(hash_including(message: '[Project/Group Import] Created new object relation')) + .at_least(:once) + + subject + end end end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 6ffe2187466..f019883a91e 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -692,6 +692,7 @@ Badge: - type ProjectCiCdSetting: - group_runners_enabled +- runner_token_expiration_interval ProjectSetting: - allow_merge_on_skipped_pipeline - has_confluence diff --git a/spec/lib/gitlab/import_export/uploads_saver_spec.rb b/spec/lib/gitlab/import_export/uploads_saver_spec.rb index 8e9be209f89..bfb18c58806 100644 --- a/spec/lib/gitlab/import_export/uploads_saver_spec.rb +++ b/spec/lib/gitlab/import_export/uploads_saver_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Gitlab::ImportExport::UploadsSaver do end it 'copies the uploads to the export path' do - saver.save + saver.save # rubocop:disable Rails/SaveBang uploads = Dir.glob(File.join(shared.export_path, 'uploads/**/*')).map { |file| File.basename(file) } @@ -54,7 +54,7 @@ RSpec.describe Gitlab::ImportExport::UploadsSaver do end it 'copies the uploads to the export path' do - saver.save + saver.save # rubocop:disable Rails/SaveBang uploads = Dir.glob(File.join(shared.export_path, 'uploads/**/*')).map { |file| File.basename(file) } diff --git a/spec/lib/gitlab/integrations/sti_type_spec.rb b/spec/lib/gitlab/integrations/sti_type_spec.rb index 70b93d6a4b5..1205b74dc9d 100644 --- a/spec/lib/gitlab/integrations/sti_type_spec.rb +++ b/spec/lib/gitlab/integrations/sti_type_spec.rb @@ -46,11 +46,11 @@ RSpec.describe Gitlab::Integrations::StiType do SQL end - let_it_be(:service) { create(:service) } + let_it_be(:integration) { create(:integration) } it 'forms SQL UPDATE statements correctly' do sql_statements = types.map do |type| - record = ActiveRecord::QueryRecorder.new { service.update_column(:type, type) } + record = ActiveRecord::QueryRecorder.new { integration.update_column(:type, type) } record.log.first end @@ -65,8 +65,6 @@ RSpec.describe Gitlab::Integrations::StiType do SQL end - let(:service) { create(:service) } - it 'forms SQL DELETE statements correctly' do sql_statements = types.map do |type| record = ActiveRecord::QueryRecorder.new { Integration.delete_by(type: type) } @@ -81,7 +79,7 @@ RSpec.describe Gitlab::Integrations::StiType do describe '#deserialize' do specify 'it deserializes type correctly', :aggregate_failures do types.each do |type| - service = create(:service, type: type) + service = create(:integration, type: type) expect(service.type).to eq('AsanaService') end @@ -90,7 +88,7 @@ RSpec.describe Gitlab::Integrations::StiType do describe '#cast' do it 'casts type as model correctly', :aggregate_failures do - create(:service, type: 'AsanaService') + create(:integration, type: 'AsanaService') types.each do |type| expect(Integration.find_by(type: type)).to be_kind_of(Integrations::Asana) @@ -100,7 +98,7 @@ RSpec.describe Gitlab::Integrations::StiType do describe '#changed?' do it 'detects changes correctly', :aggregate_failures do - service = create(:service, type: 'AsanaService') + service = create(:integration, type: 'AsanaService') types.each do |type| service.type = type diff --git a/spec/lib/gitlab/jwt_authenticatable_spec.rb b/spec/lib/gitlab/jwt_authenticatable_spec.rb index 36bb46cb250..92d5feceb75 100644 --- a/spec/lib/gitlab/jwt_authenticatable_spec.rb +++ b/spec/lib/gitlab/jwt_authenticatable_spec.rb @@ -14,17 +14,12 @@ RSpec.describe Gitlab::JwtAuthenticatable do end before do - begin - File.delete(test_class.secret_path) - rescue Errno::ENOENT - end + FileUtils.rm_f(test_class.secret_path) test_class.write_secret end - describe '.secret' do - subject(:secret) { test_class.secret } - + shared_examples 'reading secret from the secret path' do it 'returns 32 bytes' do expect(secret).to be_a(String) expect(secret.length).to eq(32) @@ -32,62 +27,170 @@ RSpec.describe Gitlab::JwtAuthenticatable do end it 'accepts a trailing newline' do - File.open(test_class.secret_path, 'a') { |f| f.write "\n" } + File.open(secret_path, 'a') { |f| f.write "\n" } expect(secret.length).to eq(32) end it 'raises an exception if the secret file cannot be read' do - File.delete(test_class.secret_path) + File.delete(secret_path) expect { secret }.to raise_exception(Errno::ENOENT) end it 'raises an exception if the secret file contains the wrong number of bytes' do - File.truncate(test_class.secret_path, 0) + File.truncate(secret_path, 0) expect { secret }.to raise_exception(RuntimeError) end end + describe '.secret' do + it_behaves_like 'reading secret from the secret path' do + subject(:secret) { test_class.secret } + + let(:secret_path) { test_class.secret_path } + end + end + + describe '.read_secret' do + it_behaves_like 'reading secret from the secret path' do + subject(:secret) { test_class.read_secret(secret_path) } + + let(:secret_path) { test_class.secret_path } + end + end + describe '.write_secret' do - it 'uses mode 0600' do - expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600) + context 'without an input' do + it 'uses mode 0600' do + expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600) + end + + it 'writes base64 data' do + bytes = Base64.strict_decode64(File.read(test_class.secret_path)) + + expect(bytes).not_to be_empty + end end - it 'writes base64 data' do - bytes = Base64.strict_decode64(File.read(test_class.secret_path)) + context 'with an input' do + let(:another_path) do + Rails.root.join('tmp', 'tests', '.jwt_another_shared_secret') + end - expect(bytes).not_to be_empty + after do + File.delete(another_path) + rescue Errno::ENOENT + end + + it 'uses mode 0600' do + test_class.write_secret(another_path) + expect(File.stat(another_path).mode & 0777).to eq(0600) + end + + it 'writes base64 data' do + test_class.write_secret(another_path) + bytes = Base64.strict_decode64(File.read(another_path)) + + expect(bytes).not_to be_empty + end end end - describe '.decode_jwt_for_issuer' do - let(:payload) { { 'iss' => 'test_issuer' } } + describe '.decode_jwt' do |decode| + let(:payload) { {} } + + context 'use included class secret' do + it 'accepts a correct header' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message) }.not_to raise_error + end + + it 'raises an error when the JWT is not signed' do + encoded_message = JWT.encode(payload, nil, 'none') + + expect { test_class.decode_jwt(encoded_message) }.to raise_error(JWT::DecodeError) + end - it 'accepts a correct header' do - encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + it 'raises an error when the header is signed with the wrong secret' do + encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.not_to raise_error + expect { test_class.decode_jwt(encoded_message) }.to raise_error(JWT::DecodeError) + end end - it 'raises an error when the JWT is not signed' do - encoded_message = JWT.encode(payload, nil, 'none') + context 'use an input secret' do + let(:another_secret) { 'another secret' } + + it 'accepts a correct header' do + encoded_message = JWT.encode(payload, another_secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message, another_secret) }.not_to raise_error + end - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + it 'raises an error when the JWT is not signed' do + encoded_message = JWT.encode(payload, nil, 'none') + + expect { test_class.decode_jwt(encoded_message, another_secret) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error when the header is signed with the wrong secret' do + encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') + + expect { test_class.decode_jwt(encoded_message, another_secret) }.to raise_error(JWT::DecodeError) + end end - it 'raises an error when the header is signed with the wrong secret' do - encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256') + context 'issuer option' do + let(:payload) { { 'iss' => 'test_issuer' } } + + it 'returns decoded payload if issuer is correct' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + payload = test_class.decode_jwt(encoded_message, issuer: 'test_issuer') - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + expect(payload[0]).to match a_hash_including('iss' => 'test_issuer') + end + + it 'raises an error when the issuer is incorrect' do + payload['iss'] = 'somebody else' + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message, issuer: 'test_issuer') }.to raise_error(JWT::DecodeError) + end end - it 'raises an error when the issuer is incorrect' do - payload['iss'] = 'somebody else' - encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + context 'iat_after option' do + it 'returns decoded payload if iat is valid' do + freeze_time do + encoded_message = JWT.encode(payload.merge(iat: (Time.current - 10.seconds).to_i), test_class.secret, 'HS256') + payload = test_class.decode_jwt(encoded_message, iat_after: Time.current - 20.seconds) + + expect(payload[0]).to match a_hash_including('iat' => be_a(Integer)) + end + end + + it 'raises an error if iat is invalid' do + encoded_message = JWT.encode(payload.merge(iat: 'wrong'), test_class.secret, 'HS256') - expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError) + expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error if iat is absent' do + encoded_message = JWT.encode(payload, test_class.secret, 'HS256') + + expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError) + end + + it 'raises an error if iat is too far in the past' do + freeze_time do + encoded_message = JWT.encode(payload.merge(iat: (Time.current - 30.seconds).to_i), test_class.secret, 'HS256') + expect do + test_class.decode_jwt(encoded_message, iat_after: Time.current - 20.seconds) + end.to raise_error(JWT::ExpiredSignature, 'Token has expired') + end + end end end end diff --git a/spec/lib/gitlab/lets_encrypt/client_spec.rb b/spec/lib/gitlab/lets_encrypt/client_spec.rb index f1284318687..1baf8749532 100644 --- a/spec/lib/gitlab/lets_encrypt/client_spec.rb +++ b/spec/lib/gitlab/lets_encrypt/client_spec.rb @@ -42,7 +42,7 @@ RSpec.describe ::Gitlab::LetsEncrypt::Client do context 'when private key is saved in settings' do let!(:saved_private_key) do key = OpenSSL::PKey::RSA.new(4096).to_pem - Gitlab::CurrentSettings.current_application_settings.update(lets_encrypt_private_key: key) + Gitlab::CurrentSettings.current_application_settings.update!(lets_encrypt_private_key: key) key end diff --git a/spec/lib/gitlab/lfs/client_spec.rb b/spec/lib/gitlab/lfs/client_spec.rb index 0f9637e8ca4..db450c79dfa 100644 --- a/spec/lib/gitlab/lfs/client_spec.rb +++ b/spec/lib/gitlab/lfs/client_spec.rb @@ -114,6 +114,52 @@ RSpec.describe Gitlab::Lfs::Client do end end + context 'server returns 200 OK with a chunked transfer request' do + before do + upload_action['header']['Transfer-Encoding'] = 'gzip, chunked' + end + + it "makes an HTTP PUT with expected parameters" do + stub_upload(object: object, headers: upload_action['header'], chunked_transfer: true).to_return(status: 200) + + lfs_client.upload!(object, upload_action, authenticated: true) + end + end + + context 'server returns 200 OK with a username and password in the URL' do + let(:base_url) { "https://someuser:testpass@example.com" } + + it "makes an HTTP PUT with expected parameters" do + stub_upload( + object: object, + headers: basic_auth_headers.merge(upload_action['header']), + url: "https://example.com/some/file" + ).to_return(status: 200) + + lfs_client.upload!(object, upload_action, authenticated: true) + end + end + + context 'no credentials in client' do + subject(:lfs_client) { described_class.new(base_url, credentials: {}) } + + context 'server returns 200 OK with credentials in URL' do + let(:creds) { 'someuser:testpass' } + let(:base_url) { "https://#{creds}@example.com" } + let(:auth_headers) { { 'Authorization' => "Basic #{Base64.strict_encode64(creds)}" } } + + it "makes an HTTP PUT with expected parameters" do + stub_upload( + object: object, + headers: auth_headers.merge(upload_action['header']), + url: "https://example.com/some/file" + ).to_return(status: 200) + + lfs_client.upload!(object, upload_action, authenticated: true) + end + end + end + context 'server returns 200 OK to an unauthenticated request' do it "makes an HTTP PUT with expected parameters" do stub = stub_upload( @@ -159,7 +205,7 @@ RSpec.describe Gitlab::Lfs::Client do it 'raises an error' do stub_upload(object: object, headers: upload_action['header']).to_return(status: 400) - expect { lfs_client.upload!(object, upload_action, authenticated: true) }.to raise_error(/Failed/) + expect { lfs_client.upload!(object, upload_action, authenticated: true) }.to raise_error(/Failed to upload object: HTTP status 400/) end end @@ -167,20 +213,25 @@ RSpec.describe Gitlab::Lfs::Client do it 'raises an error' do stub_upload(object: object, headers: upload_action['header']).to_return(status: 500) - expect { lfs_client.upload!(object, upload_action, authenticated: true) }.to raise_error(/Failed/) + expect { lfs_client.upload!(object, upload_action, authenticated: true) }.to raise_error(/Failed to upload object: HTTP status 500/) end end - def stub_upload(object:, headers:) + def stub_upload(object:, headers:, url: upload_action['href'], chunked_transfer: false) headers = { 'Content-Type' => 'application/octet-stream', - 'Content-Length' => object.size.to_s, 'User-Agent' => git_lfs_user_agent }.merge(headers) - stub_request(:put, upload_action['href']).with( + if chunked_transfer + headers['Transfer-Encoding'] = 'gzip, chunked' + else + headers['Content-Length'] = object.size.to_s + end + + stub_request(:put, url).with( body: object.file.read, - headers: headers.merge('Content-Length' => object.size.to_s) + headers: headers ) end end @@ -196,11 +247,25 @@ RSpec.describe Gitlab::Lfs::Client do end end + context 'server returns 200 OK with a username and password in the URL' do + let(:base_url) { "https://someuser:testpass@example.com" } + + it "makes an HTTP PUT with expected parameters" do + stub_verify( + object: object, + headers: basic_auth_headers.merge(verify_action['header']), + url: "https://example.com/some/file/verify" + ).to_return(status: 200) + + lfs_client.verify!(object, verify_action, authenticated: true) + end + end + context 'server returns 200 OK to an unauthenticated request' do it "makes an HTTP POST with expected parameters" do stub = stub_verify( object: object, - headers: basic_auth_headers.merge(upload_action['header']) + headers: basic_auth_headers.merge(verify_action['header']) ).to_return(status: 200) lfs_client.verify!(object, verify_action, authenticated: false) @@ -226,7 +291,7 @@ RSpec.describe Gitlab::Lfs::Client do it 'raises an error' do stub_verify(object: object, headers: verify_action['header']).to_return(status: 400) - expect { lfs_client.verify!(object, verify_action, authenticated: true) }.to raise_error(/Failed/) + expect { lfs_client.verify!(object, verify_action, authenticated: true) }.to raise_error(/Failed to verify object: HTTP status 400/) end end @@ -234,18 +299,18 @@ RSpec.describe Gitlab::Lfs::Client do it 'raises an error' do stub_verify(object: object, headers: verify_action['header']).to_return(status: 500) - expect { lfs_client.verify!(object, verify_action, authenticated: true) }.to raise_error(/Failed/) + expect { lfs_client.verify!(object, verify_action, authenticated: true) }.to raise_error(/Failed to verify object: HTTP status 500/) end end - def stub_verify(object:, headers:) + def stub_verify(object:, headers:, url: verify_action['href']) headers = { 'Accept' => git_lfs_content_type, 'Content-Type' => git_lfs_content_type, 'User-Agent' => git_lfs_user_agent }.merge(headers) - stub_request(:post, verify_action['href']).with( + stub_request(:post, url).with( body: object.to_json(only: [:oid, :size]), headers: headers ) diff --git a/spec/lib/gitlab/logger_spec.rb b/spec/lib/gitlab/logger_spec.rb new file mode 100644 index 00000000000..ed22af8355f --- /dev/null +++ b/spec/lib/gitlab/logger_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Logger do + describe '.build' do + before do + allow(described_class).to receive(:file_name_noext).and_return('log') + end + + subject { described_class.build } + + it 'builds logger using Gitlab::Logger.log_level' do + expect(described_class).to receive(:log_level).and_return(:warn) + + expect(subject.level).to eq(described_class::WARN) + end + + it 'raises ArgumentError if invalid log level' do + allow(described_class).to receive(:log_level).and_return(:invalid) + + expect { subject.level }.to raise_error(ArgumentError, 'invalid log level: invalid') + end + + using RSpec::Parameterized::TableSyntax + + where(:env_value, :resulting_level) do + 0 | described_class::DEBUG + :debug | described_class::DEBUG + 'debug' | described_class::DEBUG + 'DEBUG' | described_class::DEBUG + 'DeBuG' | described_class::DEBUG + 1 | described_class::INFO + :info | described_class::INFO + 'info' | described_class::INFO + 'INFO' | described_class::INFO + 'InFo' | described_class::INFO + 2 | described_class::WARN + :warn | described_class::WARN + 'warn' | described_class::WARN + 'WARN' | described_class::WARN + 'WaRn' | described_class::WARN + 3 | described_class::ERROR + :error | described_class::ERROR + 'error' | described_class::ERROR + 'ERROR' | described_class::ERROR + 'ErRoR' | described_class::ERROR + 4 | described_class::FATAL + :fatal | described_class::FATAL + 'fatal' | described_class::FATAL + 'FATAL' | described_class::FATAL + 'FaTaL' | described_class::FATAL + 5 | described_class::UNKNOWN + :unknown | described_class::UNKNOWN + 'unknown' | described_class::UNKNOWN + 'UNKNOWN' | described_class::UNKNOWN + 'UnKnOwN' | described_class::UNKNOWN + end + + with_them do + it 'builds logger if valid log level' do + stub_env('GITLAB_LOG_LEVEL', env_value) + + expect(subject.level).to eq(resulting_level) + end + end + end + + describe '.log_level' do + context 'if GITLAB_LOG_LEVEL is set' do + before do + stub_env('GITLAB_LOG_LEVEL', described_class::ERROR) + end + + it 'returns value of GITLAB_LOG_LEVEL' do + expect(described_class.log_level).to eq(described_class::ERROR) + end + + it 'ignores fallback' do + expect(described_class.log_level(fallback: described_class::FATAL)).to eq(described_class::ERROR) + end + end + + context 'if GITLAB_LOG_LEVEL is not set' do + it 'returns default fallback DEBUG' do + expect(described_class.log_level).to eq(described_class::DEBUG) + end + + it 'returns passed fallback' do + expect(described_class.log_level(fallback: described_class::FATAL)).to eq(described_class::FATAL) + end + end + end +end diff --git a/spec/lib/gitlab/mail_room/authenticator_spec.rb b/spec/lib/gitlab/mail_room/authenticator_spec.rb new file mode 100644 index 00000000000..44120902661 --- /dev/null +++ b/spec/lib/gitlab/mail_room/authenticator_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::MailRoom::Authenticator do + let(:yml_config) do + { + enabled: true, + address: 'address@example.com' + } + end + + let(:incoming_email_secret_path) { '/path/to/incoming_email_secret' } + let(:incoming_email_config) { yml_config.merge(secret_file: incoming_email_secret_path) } + + let(:service_desk_email_secret_path) { '/path/to/service_desk_email_secret' } + let(:service_desk_email_config) { yml_config.merge(secret_file: service_desk_email_secret_path) } + + let(:configs) do + { + incoming_email: incoming_email_config, + service_desk_email: service_desk_email_config + } + end + + before do + allow(Gitlab::MailRoom).to receive(:enabled_configs).and_return(configs) + + described_class.clear_memoization(:jwt_secret_incoming_email) + described_class.clear_memoization(:jwt_secret_service_desk_email) + end + + after do + described_class.clear_memoization(:jwt_secret_incoming_email) + described_class.clear_memoization(:jwt_secret_service_desk_email) + end + + around do |example| + freeze_time do + example.run + end + end + + describe '#verify_api_request' do + let(:incoming_email_secret) { SecureRandom.hex(16) } + let(:service_desk_email_secret) { SecureRandom.hex(16) } + let(:payload) { { iss: described_class::INTERNAL_API_REQUEST_JWT_ISSUER, iat: (Time.current - 5.minutes + 1.second).to_i } } + + before do + allow(described_class).to receive(:secret).with(:incoming_email).and_return(incoming_email_secret) + allow(described_class).to receive(:secret).with(:service_desk_email).and_return(service_desk_email_secret) + end + + context 'verify a valid token' do + it 'returns the decoded payload' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')[0]).to match a_hash_including( + "iss" => "gitlab-mailroom", + "iat" => be_a(Integer) + ) + + encoded_token = JWT.encode(payload, service_desk_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'service_desk_email')[0]).to match a_hash_including( + "iss" => "gitlab-mailroom", + "iat" => be_a(Integer) + ) + end + end + + context 'verify an invalid token' do + it 'returns false' do + encoded_token = JWT.encode(payload, 'wrong secret', 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify a valid token but wrong mailbox type' do + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'service_desk_email')).to eq(false) + end + end + + context 'verify a valid token but wrong issuer' do + let(:payload) { { iss: 'invalid_issuer' } } + + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify a valid token but expired' do + let(:payload) { { iss: described_class::INTERNAL_API_REQUEST_JWT_ISSUER, iat: (Time.current - 5.minutes - 1.second).to_i } } + + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify a valid token but wrong header field' do + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { 'a-wrong-header' => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify headers for a disabled mailbox type' do + let(:configs) { { service_desk_email: service_desk_email_config } } + + it 'returns false' do + encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256') + headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token } + + expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false) + end + end + + context 'verify headers for a non-existing mailbox type' do + it 'returns false' do + headers = { described_class::INTERNAL_API_REQUEST_HEADER => 'something' } + + expect(described_class.verify_api_request(headers, 'invalid_mailbox_type')).to eq(false) + end + end + end + + describe '#secret' do + let(:incoming_email_secret) { SecureRandom.hex(16) } + let(:service_desk_email_secret) { SecureRandom.hex(16) } + + context 'the secret is valid' do + before do + allow(described_class).to receive(:read_secret).with(incoming_email_secret_path).and_return(incoming_email_secret).once + allow(described_class).to receive(:read_secret).with(service_desk_email_secret_path).and_return(service_desk_email_secret).once + end + + it 'returns the memorized secret from a file' do + expect(described_class.secret(:incoming_email)).to eql(incoming_email_secret) + # The second call does not trigger secret read again + expect(described_class.secret(:incoming_email)).to eql(incoming_email_secret) + expect(described_class).to have_received(:read_secret).with(incoming_email_secret_path).once + + expect(described_class.secret(:service_desk_email)).to eql(service_desk_email_secret) + # The second call does not trigger secret read again + expect(described_class.secret(:service_desk_email)).to eql(service_desk_email_secret) + expect(described_class).to have_received(:read_secret).with(service_desk_email_secret_path).once + end + end + + context 'the secret file is not configured' do + let(:incoming_email_config) { yml_config } + + it 'raises a SecretConfigurationError exception' do + expect do + described_class.secret(:incoming_email) + end.to raise_error(described_class::SecretConfigurationError, "incoming_email's secret_file configuration is missing") + end + end + + context 'the secret file not found' do + before do + allow(described_class).to receive(:read_secret).with(incoming_email_secret_path).and_raise(Errno::ENOENT) + end + + it 'raises a SecretConfigurationError exception' do + expect do + described_class.secret(:incoming_email) + end.to raise_error(described_class::SecretConfigurationError, "Fail to read incoming_email's secret: No such file or directory") + end + end + end +end diff --git a/spec/lib/gitlab/mail_room/mail_room_spec.rb b/spec/lib/gitlab/mail_room/mail_room_spec.rb index 0bd1a27c65e..a4fcf71a012 100644 --- a/spec/lib/gitlab/mail_room/mail_room_spec.rb +++ b/spec/lib/gitlab/mail_room/mail_room_spec.rb @@ -30,6 +30,7 @@ RSpec.describe Gitlab::MailRoom do end before do + allow(described_class).to receive(:load_yaml).and_return(configs) described_class.instance_variable_set(:@enabled_configs, nil) end @@ -38,10 +39,6 @@ RSpec.describe Gitlab::MailRoom do end describe '#enabled_configs' do - before do - allow(described_class).to receive(:load_yaml).and_return(configs) - end - context 'when both email and address is set' do it 'returns email configs' do expect(described_class.enabled_configs.size).to eq(2) @@ -79,7 +76,7 @@ RSpec.describe Gitlab::MailRoom do let(:custom_config) { { enabled: true, address: 'address@example.com' } } it 'overwrites missing values with the default' do - expect(described_class.enabled_configs.first[:port]).to eq(Gitlab::MailRoom::DEFAULT_CONFIG[:port]) + expect(described_class.enabled_configs.each_value.first[:port]).to eq(Gitlab::MailRoom::DEFAULT_CONFIG[:port]) end end @@ -88,7 +85,7 @@ RSpec.describe Gitlab::MailRoom do it 'returns only encoming_email' do expect(described_class.enabled_configs.size).to eq(1) - expect(described_class.enabled_configs.first[:worker]).to eq('EmailReceiverWorker') + expect(described_class.enabled_configs.each_value.first[:worker]).to eq('EmailReceiverWorker') end end @@ -100,11 +97,12 @@ RSpec.describe Gitlab::MailRoom do end it 'sets redis config' do - config = described_class.enabled_configs.first - - expect(config[:redis_url]).to eq('localhost') - expect(config[:redis_db]).to eq(99) - expect(config[:sentinels]).to eq('yes, them') + config = described_class.enabled_configs.each_value.first + expect(config).to include( + redis_url: 'localhost', + redis_db: 99, + sentinels: 'yes, them' + ) end end @@ -113,7 +111,7 @@ RSpec.describe Gitlab::MailRoom do let(:custom_config) { { log_path: 'tiny_log.log' } } it 'expands the log path to an absolute value' do - new_path = Pathname.new(described_class.enabled_configs.first[:log_path]) + new_path = Pathname.new(described_class.enabled_configs.each_value.first[:log_path]) expect(new_path.absolute?).to be_truthy end end @@ -122,9 +120,48 @@ RSpec.describe Gitlab::MailRoom do let(:custom_config) { { log_path: '/dev/null' } } it 'leaves the path as-is' do - expect(described_class.enabled_configs.first[:log_path]).to eq '/dev/null' + expect(described_class.enabled_configs.each_value.first[:log_path]).to eq '/dev/null' end end end end + + describe '#enabled_mailbox_types' do + context 'when all mailbox types are enabled' do + it 'returns the mailbox types' do + expect(described_class.enabled_mailbox_types).to match(%w[incoming_email service_desk_email]) + end + end + + context 'when an mailbox_types is disabled' do + let(:incoming_email_config) { yml_config.merge(enabled: false) } + + it 'returns the mailbox types' do + expect(described_class.enabled_mailbox_types).to match(%w[service_desk_email]) + end + end + + context 'when email is disabled' do + let(:custom_config) { { enabled: false } } + + it 'returns an empty array' do + expect(described_class.enabled_mailbox_types).to match_array([]) + end + end + end + + describe '#worker_for' do + context 'matched mailbox types' do + it 'returns the constantized worker class' do + expect(described_class.worker_for('incoming_email')).to eql(EmailReceiverWorker) + expect(described_class.worker_for('service_desk_email')).to eql(ServiceDeskEmailReceiverWorker) + end + end + + context 'non-existing mailbox_type' do + it 'returns nil' do + expect(described_class.worker_for('another_mailbox_type')).to be(nil) + end + end + end end diff --git a/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb b/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb index 65c76aac10c..2407b497249 100644 --- a/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb +++ b/spec/lib/gitlab/merge_requests/commit_message_generator_spec.rb @@ -15,7 +15,8 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do ) end - let(:user) { project.creator } + let(:current_user) { create(:user, name: 'John Doe', email: 'john.doe@example.com') } + let(:author) { project.creator } let(:source_branch) { 'feature' } let(:merge_request_description) { "Merge Request Description\nNext line" } let(:merge_request_title) { 'Bugfix' } @@ -27,13 +28,13 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do target_project: project, target_branch: 'master', source_branch: source_branch, - author: user, + author: author, description: merge_request_description, title: merge_request_title ) end - subject { described_class.new(merge_request: merge_request) } + subject { described_class.new(merge_request: merge_request, current_user: current_user) } shared_examples_for 'commit message with template' do |message_template_name| it 'returns nil when template is not set in target project' do @@ -56,6 +57,19 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do end end + context 'when project has commit template with only the title' do + let(:merge_request) do + double(:merge_request, title: 'Fixes', target_project: project, to_reference: '!123', metrics: nil, merge_user: nil) + end + + let(message_template_name) { '%{title}' } + + it 'evaluates only necessary variables' do + expect(result_message).to eq 'Fixes' + expect(merge_request).not_to have_received(:to_reference) + end + end + context 'when project has commit template with closed issues' do let(message_template_name) { <<~MSG.rstrip } Merge branch '%{source_branch}' into '%{target_branch}' @@ -274,17 +288,319 @@ RSpec.describe Gitlab::MergeRequests::CommitMessageGenerator do end end end + + context 'when project has merge commit template with approvers' do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(message_template_name) { <<~MSG.rstrip } + Merge branch '%{source_branch}' into '%{target_branch}' + + %{approved_by} + MSG + + context 'and mr has no approval' do + before do + merge_request.approved_by_users = [] + end + + it 'removes variable and blank line' do + expect(result_message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + MSG + end + + context 'when there is blank line after approved_by' do + let(message_template_name) { <<~MSG.rstrip } + Merge branch '%{source_branch}' into '%{target_branch}' + + %{approved_by} + + Type: merge + MSG + + it 'removes blank line before it' do + expect(result_message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Type: merge + MSG + end + end + + context 'when there is no blank line after approved_by' do + let(message_template_name) { <<~MSG.rstrip } + Merge branch '%{source_branch}' into '%{target_branch}' + + %{approved_by} + Type: merge + MSG + + it 'does not remove blank line before it' do + expect(result_message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Type: merge + MSG + end + end + end + + context 'and mr has one approval' do + before do + merge_request.approved_by_users = [user1] + end + + it 'returns user name and email' do + expect(result_message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Approved-by: #{user1.name} <#{user1.email}> + MSG + end + end + + context 'and mr has multiple approvals' do + before do + merge_request.approved_by_users = [user1, user2] + end + + it 'returns users names and emails' do + expect(result_message).to eq <<~MSG.rstrip + Merge branch 'feature' into 'master' + + Approved-by: #{user1.name} <#{user1.email}> + Approved-by: #{user2.name} <#{user2.email}> + MSG + end + end + end + + context 'when project has merge commit template with url' do + let(message_template_name) do + "Merge Request URL is '%{url}'" + end + + context "and merge request has url" do + it "returns mr url" do + expect(result_message).to eq <<~MSG.rstrip + Merge Request URL is '#{Gitlab::UrlBuilder.build(merge_request)}' + MSG + end + end + end + + context 'when project has merge commit template with merged_by' do + let(message_template_name) do + "Merge Request merged by '%{merged_by}'" + end + + context "and current_user is passed" do + it "returns user name and email" do + expect(result_message).to eq <<~MSG.rstrip + Merge Request merged by '#{current_user.name} <#{current_user.email}>' + MSG + end + end + end + + context 'user' do + subject { described_class.new(merge_request: merge_request, current_user: nil) } + + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(message_template_name) do + "Merge Request merged by '%{merged_by}'" + end + + context 'comes from metrics' do + before do + merge_request.metrics.merged_by = user1 + end + + it "returns user name and email" do + expect(result_message).to eq <<~MSG.rstrip + Merge Request merged by '#{user1.name} <#{user1.email}>' + MSG + end + end + + context 'comes from merge_user' do + before do + merge_request.merge_user = user2 + end + + it "returns user name and email" do + expect(result_message).to eq <<~MSG.rstrip + Merge Request merged by '#{user2.name} <#{user2.email}>' + MSG + end + end + end + + context 'when project has commit template with the same variable used twice' do + let(message_template_name) { '%{title} %{title}' } + + it 'uses custom template' do + expect(result_message).to eq 'Bugfix Bugfix' + end + end + + context 'when project has commit template without any variable' do + let(message_template_name) { 'static text' } + + it 'uses custom template' do + expect(result_message).to eq 'static text' + end + end + + context 'when project has template with all variables' do + let(message_template_name) { <<~MSG.rstrip } + source_branch:%{source_branch} + target_branch:%{target_branch} + title:%{title} + issues:%{issues} + description:%{description} + first_commit:%{first_commit} + first_multiline_commit:%{first_multiline_commit} + url:%{url} + approved_by:%{approved_by} + merged_by:%{merged_by} + co_authored_by:%{co_authored_by} + MSG + + it 'uses custom template' do + expect(result_message).to eq <<~MSG.rstrip + source_branch:feature + target_branch:master + title:Bugfix + issues: + description:Merge Request Description + Next line + first_commit:Feature added + + Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> + first_multiline_commit:Feature added + + Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> + url:#{Gitlab::UrlBuilder.build(merge_request)} + approved_by: + merged_by:#{current_user.name} <#{current_user.commit_email_or_default}> + co_authored_by:Co-authored-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> + MSG + end + end + + context 'when project has merge commit template with co_authored_by' do + let(:source_branch) { 'signed-commits' } + let(message_template_name) { <<~MSG.rstrip } + %{title} + + %{co_authored_by} + MSG + + it 'uses custom template' do + expect(result_message).to eq <<~MSG.rstrip + Bugfix + + Co-authored-by: Nannie Bernhard <nannie.bernhard@example.com> + Co-authored-by: Winnie Hellmann <winnie@gitlab.com> + MSG + end + + context 'when author and merging user is one of the commit authors' do + let(:author) { create(:user, email: 'nannie.bernhard@example.com') } + + before do + merge_request.merge_user = author + end + + it 'skips his mail in coauthors' do + expect(result_message).to eq <<~MSG.rstrip + Bugfix + + Co-authored-by: Winnie Hellmann <winnie@gitlab.com> + MSG + end + end + + context 'when author and merging user is the only author of commits' do + let(:author) { create(:user, email: 'dmitriy.zaporozhets@gmail.com') } + let(:source_branch) { 'feature' } + + before do + merge_request.merge_user = author + end + + it 'skips coauthors and empty lines before it' do + expect(result_message).to eq <<~MSG.rstrip + Bugfix + MSG + end + end + end end describe '#merge_message' do let(:result_message) { subject.merge_message } it_behaves_like 'commit message with template', :merge_commit_template + + context 'when project has merge commit template with co_authored_by' do + let(:source_branch) { 'signed-commits' } + let(:merge_commit_template) { <<~MSG.rstrip } + %{title} + + %{co_authored_by} + MSG + + context 'when author and merging user are one of the commit authors' do + let(:author) { create(:user, email: 'nannie.bernhard@example.com') } + let(:merge_user) { create(:user, email: 'winnie@gitlab.com') } + + before do + merge_request.merge_user = merge_user + end + + it 'skips merging user, but does not skip merge request author' do + expect(result_message).to eq <<~MSG.rstrip + Bugfix + + Co-authored-by: Nannie Bernhard <nannie.bernhard@example.com> + MSG + end + end + end end describe '#squash_message' do let(:result_message) { subject.squash_message } it_behaves_like 'commit message with template', :squash_commit_template + + context 'when project has merge commit template with co_authored_by' do + let(:source_branch) { 'signed-commits' } + let(:squash_commit_template) { <<~MSG.rstrip } + %{title} + + %{co_authored_by} + MSG + + context 'when author and merging user are one of the commit authors' do + let(:author) { create(:user, email: 'nannie.bernhard@example.com') } + let(:merge_user) { create(:user, email: 'winnie@gitlab.com') } + + before do + merge_request.merge_user = merge_user + end + + it 'skips merge request author, but does not skip merging user' do + expect(result_message).to eq <<~MSG.rstrip + Bugfix + + Co-authored-by: Winnie Hellmann <winnie@gitlab.com> + MSG + end + end + end end end diff --git a/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb index 9cd1ef4094e..c7afc02f0af 100644 --- a/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb +++ b/spec/lib/gitlab/metrics/exporter/base_exporter_spec.rb @@ -4,13 +4,8 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do let(:settings) { double('settings') } - let(:exporter) { described_class.new(settings) } - let(:log_filename) { File.join(Rails.root, 'log', 'sidekiq_exporter.log') } - - before do - allow_any_instance_of(described_class).to receive(:log_filename).and_return(log_filename) - allow_any_instance_of(described_class).to receive(:settings).and_return(settings) - end + let(:log_enabled) { false } + let(:exporter) { described_class.new(settings, log_enabled: log_enabled, log_file: 'test_exporter.log') } describe 'when exporter is enabled' do before do @@ -61,6 +56,38 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do exporter.start.join end + + context 'logging enabled' do + let(:log_enabled) { true } + let(:logger) { instance_double(WEBrick::Log) } + + before do + allow(logger).to receive(:time_format=) + allow(logger).to receive(:info) + end + + it 'configures a WEBrick logger with the given file' do + expect(WEBrick::Log).to receive(:new).with(end_with('test_exporter.log')).and_return(logger) + + exporter + end + + it 'logs any errors during startup' do + expect(::WEBrick::Log).to receive(:new).and_return(logger) + expect(::WEBrick::HTTPServer).to receive(:new).and_raise 'fail' + expect(logger).to receive(:error) + + exporter.start + end + end + + context 'logging disabled' do + it 'configures a WEBrick logger with the null device' do + expect(WEBrick::Log).to receive(:new).with(File::NULL).and_call_original + + exporter + end + end end describe 'when thread is not alive' do @@ -111,6 +138,18 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do describe 'request handling' do using RSpec::Parameterized::TableSyntax + let(:fake_collector) do + Class.new do + def initialize(app, ...) + @app = app + end + + def call(env) + @app.call(env) + end + end + end + where(:method_class, :path, :http_status) do Net::HTTP::Get | '/metrics' | 200 Net::HTTP::Get | '/liveness' | 200 @@ -123,6 +162,8 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do allow(settings).to receive(:port).and_return(0) allow(settings).to receive(:address).and_return('127.0.0.1') + stub_const('Gitlab::Metrics::Exporter::MetricsMiddleware', fake_collector) + # We want to wrap original method # and run handling of requests # in separate thread @@ -134,8 +175,6 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do # is raised as we close listeners end end - - exporter.start.join end after do @@ -146,12 +185,25 @@ RSpec.describe Gitlab::Metrics::Exporter::BaseExporter do let(:config) { exporter.server.config } let(:request) { method_class.new(path) } - it 'responds with proper http_status' do + subject(:response) do http = Net::HTTP.new(config[:BindAddress], config[:Port]) - response = http.request(request) + http.request(request) + end + + it 'responds with proper http_status' do + exporter.start.join expect(response.code).to eq(http_status.to_s) end + + it 'collects request metrics' do + expect_next_instance_of(fake_collector) do |instance| + expect(instance).to receive(:call).and_call_original + end + + exporter.start.join + response + end end end diff --git a/spec/lib/gitlab/metrics/exporter/gc_request_middleware_spec.rb b/spec/lib/gitlab/metrics/exporter/gc_request_middleware_spec.rb new file mode 100644 index 00000000000..0c70a5de701 --- /dev/null +++ b/spec/lib/gitlab/metrics/exporter/gc_request_middleware_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Metrics::Exporter::GcRequestMiddleware do + let(:app) { double(:app) } + let(:env) { {} } + + subject(:middleware) { described_class.new(app) } + + describe '#call' do + it 'runs a major GC after the next middleware is called' do + expect(app).to receive(:call).with(env).ordered.and_return([200, {}, []]) + expect(GC).to receive(:start).ordered + + response = middleware.call(env) + + expect(response).to eq([200, {}, []]) + end + end +end diff --git a/spec/lib/gitlab/metrics/exporter/health_checks_middleware_spec.rb b/spec/lib/gitlab/metrics/exporter/health_checks_middleware_spec.rb new file mode 100644 index 00000000000..9ee46a45e7a --- /dev/null +++ b/spec/lib/gitlab/metrics/exporter/health_checks_middleware_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Metrics::Exporter::HealthChecksMiddleware do + let(:app) { double(:app) } + let(:env) { { 'PATH_INFO' => path } } + + let(:readiness_probe) { double(:readiness_probe) } + let(:liveness_probe) { double(:liveness_probe) } + let(:probe_result) { Gitlab::HealthChecks::Probes::Status.new(200, { status: 'ok' }) } + + subject(:middleware) { described_class.new(app, readiness_probe, liveness_probe) } + + describe '#call' do + context 'handling /readiness requests' do + let(:path) { '/readiness' } + + it 'handles the request' do + expect(readiness_probe).to receive(:execute).and_return(probe_result) + + response = middleware.call(env) + + expect(response).to eq([200, { 'Content-Type' => 'application/json; charset=utf-8' }, ['{"status":"ok"}']]) + end + end + + context 'handling /liveness requests' do + let(:path) { '/liveness' } + + it 'handles the request' do + expect(liveness_probe).to receive(:execute).and_return(probe_result) + + response = middleware.call(env) + + expect(response).to eq([200, { 'Content-Type' => 'application/json; charset=utf-8' }, ['{"status":"ok"}']]) + end + end + + context 'handling other requests' do + let(:path) { '/other_path' } + + it 'forwards them to the next middleware' do + expect(app).to receive(:call).with(env).and_return([201, {}, []]) + + response = middleware.call(env) + + expect(response).to eq([201, {}, []]) + end + end + end +end diff --git a/spec/lib/gitlab/metrics/exporter/metrics_middleware_spec.rb b/spec/lib/gitlab/metrics/exporter/metrics_middleware_spec.rb new file mode 100644 index 00000000000..ac5721f5974 --- /dev/null +++ b/spec/lib/gitlab/metrics/exporter/metrics_middleware_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Metrics::Exporter::MetricsMiddleware do + let(:app) { double(:app) } + let(:pid) { 'fake_exporter' } + let(:env) { { 'PATH_INFO' => '/path', 'REQUEST_METHOD' => 'GET' } } + + subject(:middleware) { described_class.new(app, pid) } + + def metric(name, method, path, status) + metric = ::Prometheus::Client.registry.get(name) + return unless metric + + values = metric.values.transform_keys { |k| k.slice(:method, :path, :pid, :code) } + values[{ method: method, path: path, pid: pid, code: status.to_s }]&.get + end + + before do + expect(app).to receive(:call).with(env).and_return([200, {}, []]) + end + + describe '#call', :prometheus do + it 'records a total requests metric' do + response = middleware.call(env) + + expect(response).to eq([200, {}, []]) + expect(metric(:exporter_http_requests_total, 'get', '/path', 200)).to eq(1.0) + end + + it 'records a request duration histogram' do + response = middleware.call(env) + + expect(response).to eq([200, {}, []]) + expect(metric(:exporter_http_request_duration_seconds, 'get', '/path', 200)).to be_a(Hash) + end + end +end diff --git a/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb deleted file mode 100644 index 75bc3ba9626..00000000000 --- a/spec/lib/gitlab/metrics/exporter/sidekiq_exporter_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Metrics::Exporter::SidekiqExporter do - let(:exporter) { described_class.new(Settings.monitoring.sidekiq_exporter) } - - after do - exporter.stop - end - - context 'with valid config' do - before do - stub_config( - monitoring: { - sidekiq_exporter: { - enabled: true, - log_enabled: false, - port: 0, - address: '127.0.0.1' - } - } - ) - end - - it 'does start thread' do - expect(exporter.start).not_to be_nil - end - - it 'does not enable logging by default' do - expect(exporter.log_filename).to eq(File::NULL) - end - end - - context 'with logging enabled' do - before do - stub_config( - monitoring: { - sidekiq_exporter: { - enabled: true, - log_enabled: true, - port: 0, - address: '127.0.0.1' - } - } - ) - end - - it 'returns a valid log filename' do - expect(exporter.log_filename).to end_with('sidekiq_exporter.log') - end - end -end diff --git a/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb b/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb index 9deaecbf41b..0531bccf4b4 100644 --- a/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb +++ b/spec/lib/gitlab/metrics/exporter/web_exporter_spec.rb @@ -24,14 +24,14 @@ RSpec.describe Gitlab::Metrics::Exporter::WebExporter do exporter.stop end - context 'when running server' do + context 'when running server', :prometheus do it 'readiness probe returns succesful status' do expect(readiness_probe.http_status).to eq(200) expect(readiness_probe.json).to include(status: 'ok') expect(readiness_probe.json).to include('web_exporter' => [{ 'status': 'ok' }]) end - it 'initializes request metrics', :prometheus do + it 'initializes request metrics' do expect(Gitlab::Metrics::RailsSlis).to receive(:initialize_request_slis_if_needed!).and_call_original http = Net::HTTP.new(exporter.server.config[:BindAddress], exporter.server.config[:Port]) @@ -42,7 +42,7 @@ RSpec.describe Gitlab::Metrics::Exporter::WebExporter do end describe '#mark_as_not_running!' do - it 'readiness probe returns a failure status' do + it 'readiness probe returns a failure status', :prometheus do exporter.mark_as_not_running! expect(readiness_probe.http_status).to eq(503) diff --git a/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb index d834b796179..e1e4877cd50 100644 --- a/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/action_cable_sampler_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Metrics::Samplers::ActionCableSampler do let(:action_cable) { instance_double(ActionCable::Server::Base) } - subject { described_class.new(action_cable: action_cable) } + subject { described_class.new(action_cable: action_cable, logger: double) } it_behaves_like 'metrics sampler', 'ACTION_CABLE_SAMPLER' diff --git a/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb index e8f8947c9e8..c88d8c17eac 100644 --- a/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/database_sampler_spec.rb @@ -62,7 +62,7 @@ RSpec.describe Gitlab::Metrics::Samplers::DatabaseSampler do end context 'when replica hosts are configured' do - let(:main_load_balancer) { ActiveRecord::Base.load_balancer } # rubocop:disable Database/MultipleDatabases + let(:main_load_balancer) { ApplicationRecord.load_balancer } let(:main_replica_host) { main_load_balancer.host } let(:ci_load_balancer) { double(:load_balancer, host_list: ci_host_list, configuration: configuration) } @@ -117,7 +117,7 @@ RSpec.describe Gitlab::Metrics::Samplers::DatabaseSampler do end context 'when the base model has replica connections' do - let(:main_load_balancer) { ActiveRecord::Base.load_balancer } # rubocop:disable Database/MultipleDatabases + let(:main_load_balancer) { ApplicationRecord.load_balancer } let(:main_replica_host) { main_load_balancer.host } let(:ci_load_balancer) { double(:load_balancer, host_list: ci_host_list, configuration: configuration) } diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb index 6f1e0480197..a4877208bcf 100644 --- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb @@ -84,7 +84,7 @@ RSpec.describe Gitlab::Metrics::Samplers::RubySampler do end describe '#sample_gc' do - let!(:sampler) { described_class.new(5) } + let!(:sampler) { described_class.new } let(:gc_reports) { [{ GC_TIME: 0.1 }, { GC_TIME: 0.2 }, { GC_TIME: 0.3 }] } diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index 1ef548ab29b..bc1d53b2ccb 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -100,7 +100,7 @@ RSpec.describe Gitlab::Middleware::Go do context 'without access to the project', :sidekiq_inline do before do - project.team.find_member(current_user).destroy + project.team.find_member(current_user).destroy! end it_behaves_like 'unauthorized' diff --git a/spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb b/spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb new file mode 100644 index 00000000000..c8dbc990f8c --- /dev/null +++ b/spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'action_dispatch' +require 'rack' +require 'request_store' + +RSpec.describe Gitlab::Middleware::WebhookRecursionDetection do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:env) { Rack::MockRequest.env_for("/").merge(headers) } + + around do |example| + Gitlab::WithRequestStore.with_request_store { example.run } + end + + describe '#call' do + subject(:call) { described_class.new(app).call(env) } + + context 'when the recursion detection header is present' do + let(:new_uuid) { SecureRandom.uuid } + let(:headers) { { 'HTTP_X_GITLAB_EVENT_UUID' => new_uuid } } + + it 'sets the request UUID from the header' do + expect(app).to receive(:call) + expect { call }.to change { Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid }.to(new_uuid) + end + end + + context 'when recursion headers are not present' do + let(:headers) { {} } + + it 'works without errors' do + expect(app).to receive(:call) + + call + + expect(Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_column_data_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_column_data_spec.rb new file mode 100644 index 00000000000..b4869f49081 --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/order_by_column_data_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::OrderByColumnData do + let(:arel_table) { Issue.arel_table } + + let(:column) do + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + column_expression: arel_table[:id], + order_expression: arel_table[:id].desc + ) + end + + subject(:column_data) { described_class.new(column, 'column_alias', arel_table) } + + describe '#arel_column' do + it 'delegates to column_expression' do + expect(column_data.arel_column).to eq(column.column_expression) + end + end + + describe '#column_for_projection' do + it 'returns the expression with AS using the original column name' do + expect(column_data.column_for_projection.to_sql).to eq('"issues"."id" AS id') + end + end + + describe '#projection' do + it 'returns the expression with AS using the specified column lias' do + expect(column_data.projection.to_sql).to eq('"issues"."id" AS column_alias') + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb index 00beacd4b35..58db22e5a9c 100644 --- a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb @@ -33,14 +33,14 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder ] end - shared_examples 'correct ordering examples' do - let(:iterator) do - Gitlab::Pagination::Keyset::Iterator.new( - scope: scope.limit(batch_size), - in_operator_optimization_options: in_operator_optimization_options - ) - end + let(:iterator) do + Gitlab::Pagination::Keyset::Iterator.new( + scope: scope.limit(batch_size), + in_operator_optimization_options: in_operator_optimization_options + ) + end + shared_examples 'correct ordering examples' do |opts = {}| let(:all_records) do all_records = [] iterator.each_batch(of: batch_size) do |records| @@ -49,8 +49,10 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder all_records end - it 'returns records in correct order' do - expect(all_records).to eq(expected_order) + unless opts[:skip_finder_query_test] + it 'returns records in correct order' do + expect(all_records).to eq(expected_order) + end end context 'when not passing the finder query' do @@ -248,4 +250,57 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder expect { described_class.new(**options).execute }.to raise_error(/The order on the scope does not support keyset pagination/) end + + context 'when ordering by SQL expression' do + let(:order) do + # ORDER BY (id * 10), id + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id_multiplied_by_ten', + order_expression: Arel.sql('(id * 10)').asc, + sql_type: 'integer' + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Issue.arel_table[:id].asc + ) + ]) + end + + let(:scope) { Issue.reorder(order) } + let(:expected_order) { issues.sort_by(&:id) } + + let(:in_operator_optimization_options) do + { + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.where(Issue.arel_table[:project_id].eq(id_expression)) } + } + end + + context 'when iterating records one by one' do + let(:batch_size) { 1 } + + it_behaves_like 'correct ordering examples', skip_finder_query_test: true + end + + context 'when iterating records with LIMIT 3' do + let(:batch_size) { 3 } + + it_behaves_like 'correct ordering examples', skip_finder_query_test: true + end + + context 'when passing finder query' do + let(:batch_size) { 3 } + + it 'raises error, loading complete rows are not supported with SQL expressions' do + in_operator_optimization_options[:finder_query] = -> (_, _) { Issue.select(:id, '(id * 10)').where(id: -1) } + + expect(in_operator_optimization_options[:finder_query]).not_to receive(:call) + + expect do + iterator.each_batch(of: batch_size) { |records| records.to_a } + end.to raise_error /The "RecordLoaderStrategy" does not support/ + end + end + end end diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb index fe95d5406dd..ab1037b318b 100644 --- a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/strategies/order_values_loader_strategy_spec.rb @@ -31,4 +31,41 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::Strategies::O ]) end end + + context 'when an SQL expression is given' do + context 'when the sql_type attribute is missing' do + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id_times_ten', + order_expression: Arel.sql('id * 10').asc + ) + ]) + end + + let(:keyset_scope) { Project.order(order) } + + it 'raises error' do + expect { strategy.initializer_columns }.to raise_error(Gitlab::Pagination::Keyset::SqlTypeMissingError) + end + end + + context 'when the sql_type_attribute is present' do + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id_times_ten', + order_expression: Arel.sql('id * 10').asc, + sql_type: 'integer' + ) + ]) + end + + let(:keyset_scope) { Project.order(order) } + + it 'returns the initializer columns' do + expect(strategy.initializer_columns).to eq(['NULL::integer AS id_times_ten']) + end + end + end end diff --git a/spec/lib/gitlab/redis/multi_store_spec.rb b/spec/lib/gitlab/redis/multi_store_spec.rb deleted file mode 100644 index 76731bb916c..00000000000 --- a/spec/lib/gitlab/redis/multi_store_spec.rb +++ /dev/null @@ -1,676 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Redis::MultiStore do - using RSpec::Parameterized::TableSyntax - - let_it_be(:redis_store_class) do - Class.new(Gitlab::Redis::Wrapper) do - def config_file_name - config_file_name = "spec/fixtures/config/redis_new_format_host.yml" - Rails.root.join(config_file_name).to_s - end - - def self.name - 'Sessions' - end - end - end - - let_it_be(:primary_db) { 1 } - let_it_be(:secondary_db) { 2 } - let_it_be(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) } - let_it_be(:secondary_store) { create_redis_store(redis_store_class.params, db: secondary_db, serializer: nil) } - let_it_be(:instance_name) { 'TestStore' } - let_it_be(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)} - - subject { multi_store.send(name, *args) } - - before do - skip_feature_flags_yaml_validation - skip_default_enabled_yaml_check - end - - after(:all) do - primary_store.flushdb - secondary_store.flushdb - end - - context 'when primary_store is nil' do - let(:multi_store) { described_class.new(nil, secondary_store, instance_name)} - - it 'fails with exception' do - expect { multi_store }.to raise_error(ArgumentError, /primary_store is required/) - end - end - - context 'when secondary_store is nil' do - let(:multi_store) { described_class.new(primary_store, nil, instance_name)} - - it 'fails with exception' do - expect { multi_store }.to raise_error(ArgumentError, /secondary_store is required/) - end - end - - context 'when instance_name is nil' do - let(:instance_name) { nil } - let(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)} - - it 'fails with exception' do - expect { multi_store }.to raise_error(ArgumentError, /instance_name is required/) - end - end - - context 'when primary_store is not a ::Redis instance' do - before do - allow(primary_store).to receive(:is_a?).with(::Redis).and_return(false) - end - - it 'fails with exception' do - expect { described_class.new(primary_store, secondary_store, instance_name) }.to raise_error(ArgumentError, /invalid primary_store/) - end - end - - context 'when secondary_store is not a ::Redis instance' do - before do - allow(secondary_store).to receive(:is_a?).with(::Redis).and_return(false) - end - - it 'fails with exception' do - expect { described_class.new(primary_store, secondary_store, instance_name) }.to raise_error(ArgumentError, /invalid secondary_store/) - end - end - - context 'with READ redis commands' do - let_it_be(:key1) { "redis:{1}:key_a" } - let_it_be(:key2) { "redis:{1}:key_b" } - let_it_be(:value1) { "redis_value1"} - let_it_be(:value2) { "redis_value2"} - let_it_be(:skey) { "redis:set:key" } - let_it_be(:keys) { [key1, key2] } - let_it_be(:values) { [value1, value2] } - let_it_be(:svalues) { [value2, value1] } - - where(:case_name, :name, :args, :value, :block) do - 'execute :get command' | :get | ref(:key1) | ref(:value1) | nil - 'execute :mget command' | :mget | ref(:keys) | ref(:values) | nil - 'execute :mget with block' | :mget | ref(:keys) | ref(:values) | ->(value) { value } - 'execute :smembers command' | :smembers | ref(:skey) | ref(:svalues) | nil - 'execute :scard command' | :scard | ref(:skey) | 2 | nil - end - - before(:all) do - primary_store.multi do |multi| - multi.set(key1, value1) - multi.set(key2, value2) - multi.sadd(skey, value1) - multi.sadd(skey, value2) - end - - secondary_store.multi do |multi| - multi.set(key1, value1) - multi.set(key2, value2) - multi.sadd(skey, value1) - multi.sadd(skey, value2) - end - end - - RSpec.shared_examples_for 'reads correct value' do - it 'returns the correct value' do - if value.is_a?(Array) - # :smembers does not guarantee the order it will return the values (unsorted set) - is_expected.to match_array(value) - else - is_expected.to eq(value) - end - end - end - - RSpec.shared_examples_for 'fallback read from the secondary store' do - let(:counter) { Gitlab::Metrics::NullMetric.instance } - - before do - allow(Gitlab::Metrics).to receive(:counter).and_return(counter) - end - - it 'fallback and execute on secondary instance' do - expect(secondary_store).to receive(name).with(*args).and_call_original - - subject - end - - it 'logs the ReadFromPrimaryError' do - expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(Gitlab::Redis::MultiStore::ReadFromPrimaryError), - hash_including(command_name: name, extra: hash_including(instance_name: instance_name))) - - subject - end - - it 'increment read fallback count metrics' do - expect(counter).to receive(:increment).with(command: name, instance_name: instance_name) - - subject - end - - include_examples 'reads correct value' - - context 'when fallback read from the secondary instance raises an exception' do - before do - allow(secondary_store).to receive(name).with(*args).and_raise(StandardError) - end - - it 'fails with exception' do - expect { subject }.to raise_error(StandardError) - end - end - end - - RSpec.shared_examples_for 'secondary store' do - it 'execute on the secondary instance' do - expect(secondary_store).to receive(name).with(*args).and_call_original - - subject - end - - include_examples 'reads correct value' - - it 'does not execute on the primary store' do - expect(primary_store).not_to receive(name) - - subject - end - end - - with_them do - describe "#{name}" do - before do - allow(primary_store).to receive(name).and_call_original - allow(secondary_store).to receive(name).and_call_original - end - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true) - end - - context 'when reading from the primary is successful' do - it 'returns the correct value' do - expect(primary_store).to receive(name).with(*args).and_call_original - - subject - end - - it 'does not execute on the secondary store' do - expect(secondary_store).not_to receive(name) - - subject - end - - include_examples 'reads correct value' - end - - context 'when reading from primary instance is raising an exception' do - before do - allow(primary_store).to receive(name).with(*args).and_raise(StandardError) - allow(Gitlab::ErrorTracking).to receive(:log_exception) - end - - it 'logs the exception' do - expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), - hash_including(extra: hash_including(:multi_store_error_message, instance_name: instance_name), - command_name: name)) - - subject - end - - include_examples 'fallback read from the secondary store' - end - - context 'when reading from primary instance return no value' do - before do - allow(primary_store).to receive(name).and_return(nil) - end - - include_examples 'fallback read from the secondary store' - end - - context 'when the command is executed within pipelined block' do - subject do - multi_store.pipelined do - multi_store.send(name, *args) - end - end - - it 'is executed only 1 time on primary instance' do - expect(primary_store).to receive(name).with(*args).once - - subject - end - end - - if params[:block] - subject do - multi_store.send(name, *args, &block) - end - - context 'when block is provided' do - it 'yields to the block' do - expect(primary_store).to receive(name).and_yield(value) - - subject - end - - include_examples 'reads correct value' - end - end - end - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false) - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: false) - end - - it_behaves_like 'secondary store' - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: true) - end - - it 'execute on the primary instance' do - expect(primary_store).to receive(name).with(*args).and_call_original - - subject - end - - include_examples 'reads correct value' - - it 'does not execute on the secondary store' do - expect(secondary_store).not_to receive(name) - - subject - end - end - end - - context 'with both primary and secondary store using same redis instance' do - let(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) } - let(:secondary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) } - let(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)} - - it_behaves_like 'secondary store' - end - end - end - end - - context 'with WRITE redis commands' do - let_it_be(:key1) { "redis:{1}:key_a" } - let_it_be(:key2) { "redis:{1}:key_b" } - let_it_be(:value1) { "redis_value1"} - let_it_be(:value2) { "redis_value2"} - let_it_be(:key1_value1) { [key1, value1] } - let_it_be(:key1_value2) { [key1, value2] } - let_it_be(:ttl) { 10 } - let_it_be(:key1_ttl_value1) { [key1, ttl, value1] } - let_it_be(:skey) { "redis:set:key" } - let_it_be(:svalues1) { [value2, value1] } - let_it_be(:svalues2) { [value1] } - let_it_be(:skey_value1) { [skey, value1] } - let_it_be(:skey_value2) { [skey, value2] } - - where(:case_name, :name, :args, :expected_value, :verification_name, :verification_args) do - 'execute :set command' | :set | ref(:key1_value1) | ref(:value1) | :get | ref(:key1) - 'execute :setnx command' | :setnx | ref(:key1_value2) | ref(:value1) | :get | ref(:key2) - 'execute :setex command' | :setex | ref(:key1_ttl_value1) | ref(:ttl) | :ttl | ref(:key1) - 'execute :sadd command' | :sadd | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey) - 'execute :srem command' | :srem | ref(:skey_value1) | [] | :smembers | ref(:skey) - 'execute :del command' | :del | ref(:key2) | nil | :get | ref(:key2) - 'execute :flushdb command' | :flushdb | nil | 0 | :dbsize | nil - end - - before do - primary_store.flushdb - secondary_store.flushdb - - primary_store.multi do |multi| - multi.set(key2, value1) - multi.sadd(skey, value1) - end - - secondary_store.multi do |multi| - multi.set(key2, value1) - multi.sadd(skey, value1) - end - end - - RSpec.shared_examples_for 'verify that store contains values' do |store| - it "#{store} redis store contains correct values", :aggregate_errors do - subject - - redis_store = multi_store.send(store) - - if expected_value.is_a?(Array) - # :smembers does not guarantee the order it will return the values - expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value) - else - expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value) - end - end - end - - with_them do - describe "#{name}" do - let(:expected_args) {args || no_args } - - before do - allow(primary_store).to receive(name).and_call_original - allow(secondary_store).to receive(name).and_call_original - end - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true) - end - - context 'when executing on primary instance is successful' do - it 'executes on both primary and secondary redis store', :aggregate_errors do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).to receive(name).with(*expected_args).and_call_original - - subject - end - - include_examples 'verify that store contains values', :primary_store - include_examples 'verify that store contains values', :secondary_store - end - - context 'when executing on the primary instance is raising an exception' do - before do - allow(primary_store).to receive(name).with(*expected_args).and_raise(StandardError) - allow(Gitlab::ErrorTracking).to receive(:log_exception) - end - - it 'logs the exception and execute on secondary instance', :aggregate_errors do - expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), - hash_including(extra: hash_including(:multi_store_error_message), command_name: name)) - expect(secondary_store).to receive(name).with(*expected_args).and_call_original - - subject - end - - include_examples 'verify that store contains values', :secondary_store - end - - context 'when the command is executed within pipelined block' do - subject do - multi_store.pipelined do - multi_store.send(name, *args) - end - end - - it 'is executed only 1 time on each instance', :aggregate_errors do - expect(primary_store).to receive(name).with(*expected_args).once - expect(secondary_store).to receive(name).with(*expected_args).once - - subject - end - - include_examples 'verify that store contains values', :primary_store - include_examples 'verify that store contains values', :secondary_store - end - end - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false) - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: false) - end - - it 'executes only on the secondary redis store', :aggregate_errors do - expect(secondary_store).to receive(name).with(*expected_args) - expect(primary_store).not_to receive(name).with(*expected_args) - - subject - end - - include_examples 'verify that store contains values', :secondary_store - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: true) - end - - it 'executes only on the primary_redis redis store', :aggregate_errors do - expect(primary_store).to receive(name).with(*expected_args) - expect(secondary_store).not_to receive(name).with(*expected_args) - - subject - end - - include_examples 'verify that store contains values', :primary_store - end - end - end - end - end - - context 'with unsupported command' do - let(:counter) { Gitlab::Metrics::NullMetric.instance } - - before do - primary_store.flushdb - secondary_store.flushdb - allow(Gitlab::Metrics).to receive(:counter).and_return(counter) - end - - let_it_be(:key) { "redis:counter" } - - subject { multi_store.incr(key) } - - it 'executes method missing' do - expect(multi_store).to receive(:method_missing) - - subject - end - - context 'when command is not in SKIP_LOG_METHOD_MISSING_FOR_COMMANDS' do - it 'logs MethodMissingError' do - expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(Gitlab::Redis::MultiStore::MethodMissingError), - hash_including(command_name: :incr, extra: hash_including(instance_name: instance_name))) - - subject - end - - it 'increments method missing counter' do - expect(counter).to receive(:increment).with(command: :incr, instance_name: instance_name) - - subject - end - end - - context 'when command is in SKIP_LOG_METHOD_MISSING_FOR_COMMANDS' do - subject { multi_store.info } - - it 'does not log MethodMissingError' do - expect(Gitlab::ErrorTracking).not_to receive(:log_exception) - - subject - end - - it 'does not increment method missing counter' do - expect(counter).not_to receive(:increment) - - subject - end - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: true) - end - - it 'fallback and executes only on the secondary store', :aggregate_errors do - expect(primary_store).to receive(:incr).with(key).and_call_original - expect(secondary_store).not_to receive(:incr) - - subject - end - - it 'correct value is stored on the secondary store', :aggregate_errors do - subject - - expect(secondary_store.get(key)).to be_nil - expect(primary_store.get(key)).to eq('1') - end - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: false) - end - - it 'fallback and executes only on the secondary store', :aggregate_errors do - expect(secondary_store).to receive(:incr).with(key).and_call_original - expect(primary_store).not_to receive(:incr) - - subject - end - - it 'correct value is stored on the secondary store', :aggregate_errors do - subject - - expect(primary_store.get(key)).to be_nil - expect(secondary_store.get(key)).to eq('1') - end - end - - context 'when the command is executed within pipelined block' do - subject do - multi_store.pipelined do - multi_store.incr(key) - end - end - - it 'is executed only 1 time on each instance', :aggregate_errors do - expect(primary_store).to receive(:incr).with(key).once - expect(secondary_store).to receive(:incr).with(key).once - - subject - end - - it "both redis stores are containing correct values", :aggregate_errors do - subject - - expect(primary_store.get(key)).to eq('1') - expect(secondary_store.get(key)).to eq('1') - end - end - end - - describe '#to_s' do - subject { multi_store.to_s } - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true) - end - - it 'returns same value as primary_store' do - is_expected.to eq(primary_store.to_s) - end - end - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false) - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: true) - end - - it 'returns same value as primary_store' do - is_expected.to eq(primary_store.to_s) - end - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: false) - end - - it 'returns same value as primary_store' do - is_expected.to eq(secondary_store.to_s) - end - end - end - end - - describe '#is_a?' do - it 'returns true for ::Redis::Store' do - expect(multi_store.is_a?(::Redis::Store)).to be true - end - end - - describe '#use_primary_and_secondary_stores?' do - context 'with feature flag :use_primary_and_secondary_stores_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true) - end - - it 'multi store is disabled' do - expect(multi_store.use_primary_and_secondary_stores?).to be true - end - end - - context 'with feature flag :use_primary_and_secondary_stores_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false) - end - - it 'multi store is disabled' do - expect(multi_store.use_primary_and_secondary_stores?).to be false - end - end - end - - describe '#use_primary_store_as_default?' do - context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: true) - end - - it 'multi store is disabled' do - expect(multi_store.use_primary_store_as_default?).to be true - end - end - - context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do - before do - stub_feature_flags(use_primary_store_as_default_for_test_store: false) - end - - it 'multi store is disabled' do - expect(multi_store.use_primary_store_as_default?).to be false - end - end - end - - def create_redis_store(options, extras = {}) - ::Redis::Store.new(options.merge(extras)) - end -end diff --git a/spec/lib/gitlab/redis/sessions_spec.rb b/spec/lib/gitlab/redis/sessions_spec.rb index 6ecbbf3294d..b02864cb73d 100644 --- a/spec/lib/gitlab/redis/sessions_spec.rb +++ b/spec/lib/gitlab/redis/sessions_spec.rb @@ -6,31 +6,16 @@ RSpec.describe Gitlab::Redis::Sessions do it_behaves_like "redis_new_instance_shared_examples", 'sessions', Gitlab::Redis::SharedState describe 'redis instance used in connection pool' do - before do + around do |example| clear_pool - end - - after do + example.run + ensure clear_pool end - context 'when redis.sessions configuration is not provided' do - it 'uses ::Redis instance' do - expect(described_class).to receive(:config_fallback?).and_return(true) - - described_class.pool.with do |redis_instance| - expect(redis_instance).to be_instance_of(::Redis) - end - end - end - - context 'when redis.sessions configuration is provided' do - it 'instantiates an instance of MultiStore' do - expect(described_class).to receive(:config_fallback?).and_return(false) - - described_class.pool.with do |redis_instance| - expect(redis_instance).to be_instance_of(::Gitlab::Redis::MultiStore) - end + it 'uses ::Redis instance' do + described_class.pool.with do |redis_instance| + expect(redis_instance).to be_instance_of(::Redis) end end @@ -44,49 +29,9 @@ RSpec.describe Gitlab::Redis::Sessions do describe '#store' do subject(:store) { described_class.store(namespace: described_class::SESSION_NAMESPACE) } - context 'when redis.sessions configuration is NOT provided' do - it 'instantiates ::Redis instance' do - expect(described_class).to receive(:config_fallback?).and_return(true) - expect(store).to be_instance_of(::Redis::Store) - end - end - - context 'when redis.sessions configuration is provided' do - let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" } - let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" } - - before do - redis_clear_raw_config!(Gitlab::Redis::Sessions) - redis_clear_raw_config!(Gitlab::Redis::SharedState) - allow(described_class).to receive(:config_fallback?).and_return(false) - end - - after do - redis_clear_raw_config!(Gitlab::Redis::Sessions) - redis_clear_raw_config!(Gitlab::Redis::SharedState) - end - - # Check that Gitlab::Redis::Sessions is configured as MultiStore with proper attrs. - it 'instantiates an instance of MultiStore', :aggregate_failures do - expect(described_class).to receive(:config_file_name).and_return(config_new_format_host) - expect(::Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(config_new_format_socket) - - expect(store).to be_instance_of(::Gitlab::Redis::MultiStore) - - expect(store.primary_store.to_s).to eq("Redis Client connected to test-host:6379 against DB 99 with namespace session:gitlab") - expect(store.secondary_store.to_s).to eq("Redis Client connected to /path/to/redis.sock against DB 0 with namespace session:gitlab") - - expect(store.instance_name).to eq('Sessions') - end - - context 'when MultiStore correctly configured' do - before do - allow(described_class).to receive(:config_file_name).and_return(config_new_format_host) - allow(::Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(config_new_format_socket) - end - - it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_sessions, :use_primary_store_as_default_for_sessions - end + # Check that Gitlab::Redis::Sessions is configured as RedisStore. + it 'instantiates an instance of Redis::Store' do + expect(store).to be_instance_of(::Redis::Store) end end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 83f85cc73d0..8d67350f0f3 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -433,6 +433,7 @@ RSpec.describe Gitlab::Regex do describe '.nuget_version_regex' do subject { described_class.nuget_version_regex } + it { is_expected.to match('1.2') } it { is_expected.to match('1.2.3') } it { is_expected.to match('1.2.3.4') } it { is_expected.to match('1.2.3.4-stable.1') } @@ -440,7 +441,6 @@ RSpec.describe Gitlab::Regex do it { is_expected.to match('1.2.3-alpha.3') } it { is_expected.to match('1.0.7+r3456') } it { is_expected.not_to match('1') } - it { is_expected.not_to match('1.2') } it { is_expected.not_to match('1./2.3') } it { is_expected.not_to match('../../../../../1.2.3') } it { is_expected.not_to match('%2e%2e%2f1.2.3') } diff --git a/spec/lib/gitlab/search/params_spec.rb b/spec/lib/gitlab/search/params_spec.rb index 6d15337b872..13770e550ec 100644 --- a/spec/lib/gitlab/search/params_spec.rb +++ b/spec/lib/gitlab/search/params_spec.rb @@ -133,4 +133,12 @@ RSpec.describe Gitlab::Search::Params do end end end + + describe '#email_lookup?' do + it 'is true if at least 1 word in search is an email' do + expect(described_class.new({ search: 'email@example.com' })).to be_email_lookup + expect(described_class.new({ search: 'foo email@example.com bar' })).to be_email_lookup + expect(described_class.new({ search: 'foo bar' })).not_to be_email_lookup + end + end end diff --git a/spec/lib/gitlab/shard_health_cache_spec.rb b/spec/lib/gitlab/shard_health_cache_spec.rb index 5c47ac7e9a0..0c25cc7dab5 100644 --- a/spec/lib/gitlab/shard_health_cache_spec.rb +++ b/spec/lib/gitlab/shard_health_cache_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::ShardHealthCache, :clean_gitlab_redis_cache do let(:shards) { %w(foo bar) } before do - described_class.update(shards) + described_class.update(shards) # rubocop:disable Rails/SaveBang end describe '.clear' do @@ -24,7 +24,7 @@ RSpec.describe Gitlab::ShardHealthCache, :clean_gitlab_redis_cache do it 'replaces the existing set' do new_set = %w(test me more) - described_class.update(new_set) + described_class.update(new_set) # rubocop:disable Rails/SaveBang expect(described_class.cached_healthy_shards).to match_array(new_set) end @@ -36,7 +36,7 @@ RSpec.describe Gitlab::ShardHealthCache, :clean_gitlab_redis_cache do end it 'returns 0 if no shards are available' do - described_class.update([]) + described_class.update([]) # rubocop:disable Rails/SaveBang expect(described_class.healthy_shard_count).to eq(0) end diff --git a/spec/lib/gitlab/sherlock/collection_spec.rb b/spec/lib/gitlab/sherlock/collection_spec.rb deleted file mode 100644 index fcf8e6638f8..00000000000 --- a/spec/lib/gitlab/sherlock/collection_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::Collection do - let(:collection) { described_class.new } - - let(:transaction) do - Gitlab::Sherlock::Transaction.new('POST', '/cat_pictures') - end - - describe '#add' do - it 'adds a new transaction' do - collection.add(transaction) - - expect(collection).not_to be_empty - end - - it 'is aliased as <<' do - collection << transaction - - expect(collection).not_to be_empty - end - end - - describe '#each' do - it 'iterates over every transaction' do - collection.add(transaction) - - expect { |b| collection.each(&b) }.to yield_with_args(transaction) - end - end - - describe '#clear' do - it 'removes all transactions' do - collection.add(transaction) - - collection.clear - - expect(collection).to be_empty - end - end - - describe '#empty?' do - it 'returns true for an empty collection' do - expect(collection).to be_empty - end - - it 'returns false for a collection with a transaction' do - collection.add(transaction) - - expect(collection).not_to be_empty - end - end - - describe '#find_transaction' do - it 'returns the transaction for the given ID' do - collection.add(transaction) - - expect(collection.find_transaction(transaction.id)).to eq(transaction) - end - - it 'returns nil when no transaction could be found' do - collection.add(transaction) - - expect(collection.find_transaction('cats')).to be_nil - end - end - - describe '#newest_first' do - it 'returns transactions sorted from new to old' do - trans1 = Gitlab::Sherlock::Transaction.new('POST', '/cat_pictures') - trans2 = Gitlab::Sherlock::Transaction.new('POST', '/more_cat_pictures') - - allow(trans1).to receive(:finished_at).and_return(Time.utc(2015, 1, 1)) - allow(trans2).to receive(:finished_at).and_return(Time.utc(2015, 1, 2)) - - collection.add(trans1) - collection.add(trans2) - - expect(collection.newest_first).to eq([trans2, trans1]) - end - end -end diff --git a/spec/lib/gitlab/sherlock/file_sample_spec.rb b/spec/lib/gitlab/sherlock/file_sample_spec.rb deleted file mode 100644 index 8a1aa51e2d4..00000000000 --- a/spec/lib/gitlab/sherlock/file_sample_spec.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::FileSample do - let(:sample) { described_class.new(__FILE__, [], 150.4, 2) } - - describe '#id' do - it 'returns the ID' do - expect(sample.id).to be_an_instance_of(String) - end - end - - describe '#file' do - it 'returns the file path' do - expect(sample.file).to eq(__FILE__) - end - end - - describe '#line_samples' do - it 'returns the line samples' do - expect(sample.line_samples).to eq([]) - end - end - - describe '#events' do - it 'returns the total number of events' do - expect(sample.events).to eq(2) - end - end - - describe '#duration' do - it 'returns the total execution time' do - expect(sample.duration).to eq(150.4) - end - end - - describe '#relative_path' do - it 'returns the relative path' do - expect(sample.relative_path) - .to eq('spec/lib/gitlab/sherlock/file_sample_spec.rb') - end - end - - describe '#to_param' do - it 'returns the sample ID' do - expect(sample.to_param).to eq(sample.id) - end - end - - describe '#source' do - it 'returns the contents of the file' do - expect(sample.source).to eq(File.read(__FILE__)) - end - end -end diff --git a/spec/lib/gitlab/sherlock/line_profiler_spec.rb b/spec/lib/gitlab/sherlock/line_profiler_spec.rb deleted file mode 100644 index 2220a2cafc8..00000000000 --- a/spec/lib/gitlab/sherlock/line_profiler_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::LineProfiler do - let(:profiler) { described_class.new } - - describe '#profile' do - it 'runs the profiler when using MRI' do - allow(profiler).to receive(:mri?).and_return(true) - allow(profiler).to receive(:profile_mri) - - profiler.profile { 'cats' } - end - - it 'raises NotImplementedError when profiling an unsupported platform' do - allow(profiler).to receive(:mri?).and_return(false) - - expect { profiler.profile { 'cats' } }.to raise_error(NotImplementedError) - end - end - - describe '#profile_mri' do - it 'returns an Array containing the return value and profiling samples' do - allow(profiler).to receive(:lineprof) - .and_yield - .and_return({ __FILE__ => [[0, 0, 0, 0]] }) - - retval, samples = profiler.profile_mri { 42 } - - expect(retval).to eq(42) - expect(samples).to eq([]) - end - end - - describe '#aggregate_rblineprof' do - let(:raw_samples) do - { __FILE__ => [[30000, 30000, 5, 0], [15000, 15000, 4, 0]] } - end - - it 'returns an Array of FileSample objects' do - samples = profiler.aggregate_rblineprof(raw_samples) - - expect(samples).to be_an_instance_of(Array) - expect(samples[0]).to be_an_instance_of(Gitlab::Sherlock::FileSample) - end - - describe 'the first FileSample object' do - let(:file_sample) do - profiler.aggregate_rblineprof(raw_samples)[0] - end - - it 'uses the correct file path' do - expect(file_sample.file).to eq(__FILE__) - end - - it 'contains a list of line samples' do - line_sample = file_sample.line_samples[0] - - expect(line_sample).to be_an_instance_of(Gitlab::Sherlock::LineSample) - - expect(line_sample.duration).to eq(15.0) - expect(line_sample.events).to eq(4) - end - - it 'contains the total file execution time' do - expect(file_sample.duration).to eq(30.0) - end - - it 'contains the total amount of file events' do - expect(file_sample.events).to eq(5) - end - end - end -end diff --git a/spec/lib/gitlab/sherlock/line_sample_spec.rb b/spec/lib/gitlab/sherlock/line_sample_spec.rb deleted file mode 100644 index db031377787..00000000000 --- a/spec/lib/gitlab/sherlock/line_sample_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::LineSample do - let(:sample) { described_class.new(150.0, 4) } - - describe '#duration' do - it 'returns the duration' do - expect(sample.duration).to eq(150.0) - end - end - - describe '#events' do - it 'returns the amount of events' do - expect(sample.events).to eq(4) - end - end - - describe '#percentage_of' do - it 'returns the percentage of 1500.0' do - expect(sample.percentage_of(1500.0)).to be_within(0.1).of(10.0) - end - end - - describe '#majority_of' do - it 'returns true if the sample takes up the majority of the given duration' do - expect(sample.majority_of?(500.0)).to eq(true) - end - - it "returns false if the sample doesn't take up the majority of the given duration" do - expect(sample.majority_of?(1500.0)).to eq(false) - end - end -end diff --git a/spec/lib/gitlab/sherlock/location_spec.rb b/spec/lib/gitlab/sherlock/location_spec.rb deleted file mode 100644 index 4a8b5dffba2..00000000000 --- a/spec/lib/gitlab/sherlock/location_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::Location do - let(:location) { described_class.new(__FILE__, 1) } - - describe 'from_ruby_location' do - it 'creates a Location from a Thread::Backtrace::Location' do - input = caller_locations[0] - output = described_class.from_ruby_location(input) - - expect(output).to be_an_instance_of(described_class) - expect(output.path).to eq(input.path) - expect(output.line).to eq(input.lineno) - end - end - - describe '#path' do - it 'returns the file path' do - expect(location.path).to eq(__FILE__) - end - end - - describe '#line' do - it 'returns the line number' do - expect(location.line).to eq(1) - end - end - - describe '#application?' do - it 'returns true for an application frame' do - expect(location.application?).to eq(true) - end - - it 'returns false for a non application frame' do - loc = described_class.new('/tmp/cats.rb', 1) - - expect(loc.application?).to eq(false) - end - end -end diff --git a/spec/lib/gitlab/sherlock/middleware_spec.rb b/spec/lib/gitlab/sherlock/middleware_spec.rb deleted file mode 100644 index 645bde6681d..00000000000 --- a/spec/lib/gitlab/sherlock/middleware_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::Middleware do - let(:app) { double(:app) } - let(:middleware) { described_class.new(app) } - - describe '#call' do - describe 'when instrumentation is enabled' do - it 'instruments a request' do - allow(middleware).to receive(:instrument?).and_return(true) - allow(middleware).to receive(:call_with_instrumentation) - - middleware.call({}) - end - end - - describe 'when instrumentation is disabled' do - it "doesn't instrument a request" do - allow(middleware).to receive(:instrument).and_return(false) - allow(app).to receive(:call) - - middleware.call({}) - end - end - end - - describe '#call_with_instrumentation' do - it 'instruments a request' do - trans = double(:transaction) - retval = 'cats are amazing' - env = {} - - allow(app).to receive(:call).with(env).and_return(retval) - allow(middleware).to receive(:transaction_from_env).and_return(trans) - allow(trans).to receive(:run).and_yield.and_return(retval) - allow(Gitlab::Sherlock.collection).to receive(:add).with(trans) - - middleware.call_with_instrumentation(env) - end - end - - describe '#instrument?' do - it 'returns false for a text/css request' do - env = { 'HTTP_ACCEPT' => 'text/css', 'REQUEST_URI' => '/' } - - expect(middleware.instrument?(env)).to eq(false) - end - - it 'returns false for a request to a Sherlock route' do - env = { - 'HTTP_ACCEPT' => 'text/html', - 'REQUEST_URI' => '/sherlock/transactions' - } - - expect(middleware.instrument?(env)).to eq(false) - end - - it 'returns true for a request that should be instrumented' do - env = { - 'HTTP_ACCEPT' => 'text/html', - 'REQUEST_URI' => '/cats' - } - - expect(middleware.instrument?(env)).to eq(true) - end - end - - describe '#transaction_from_env' do - it 'returns a Transaction' do - env = { - 'HTTP_ACCEPT' => 'text/html', - 'REQUEST_URI' => '/cats' - } - - expect(middleware.transaction_from_env(env)) - .to be_an_instance_of(Gitlab::Sherlock::Transaction) - end - end -end diff --git a/spec/lib/gitlab/sherlock/query_spec.rb b/spec/lib/gitlab/sherlock/query_spec.rb deleted file mode 100644 index b8dfd082c37..00000000000 --- a/spec/lib/gitlab/sherlock/query_spec.rb +++ /dev/null @@ -1,115 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::Query do - let(:started_at) { Time.utc(2015, 1, 1) } - let(:finished_at) { started_at + 5 } - - let(:query) do - described_class.new('SELECT COUNT(*) FROM users', started_at, finished_at) - end - - describe 'new_with_bindings' do - it 'returns a Query' do - sql = 'SELECT COUNT(*) FROM users WHERE id = $1' - bindings = [[double(:column), 10]] - - query = described_class - .new_with_bindings(sql, bindings, started_at, finished_at) - - expect(query.query).to eq('SELECT COUNT(*) FROM users WHERE id = 10;') - end - end - - describe '#id' do - it 'returns a String' do - expect(query.id).to be_an_instance_of(String) - end - end - - describe '#query' do - it 'returns the query with a trailing semi-colon' do - expect(query.query).to eq('SELECT COUNT(*) FROM users;') - end - end - - describe '#started_at' do - it 'returns the start time' do - expect(query.started_at).to eq(started_at) - end - end - - describe '#finished_at' do - it 'returns the completion time' do - expect(query.finished_at).to eq(finished_at) - end - end - - describe '#backtrace' do - it 'returns the backtrace' do - expect(query.backtrace).to be_an_instance_of(Array) - end - end - - describe '#duration' do - it 'returns the duration in milliseconds' do - expect(query.duration).to be_within(0.1).of(5000.0) - end - end - - describe '#to_param' do - it 'returns the query ID' do - expect(query.to_param).to eq(query.id) - end - end - - describe '#formatted_query' do - it 'returns a formatted version of the query' do - expect(query.formatted_query).to eq(<<-EOF.strip) -SELECT COUNT(*) -FROM users; - EOF - end - end - - describe '#last_application_frame' do - it 'returns the last application frame' do - frame = query.last_application_frame - - expect(frame).to be_an_instance_of(Gitlab::Sherlock::Location) - expect(frame.path).to eq(__FILE__) - end - end - - describe '#application_backtrace' do - it 'returns an Array of application frames' do - frames = query.application_backtrace - - expect(frames).to be_an_instance_of(Array) - expect(frames).not_to be_empty - - frames.each do |frame| - expect(frame.path).to start_with(Rails.root.to_s) - end - end - end - - describe '#explain' do - it 'returns the query plan as a String' do - lines = [ - ['Aggregate (cost=123 rows=1)'], - [' -> Index Only Scan using index_cats_are_amazing'] - ] - - result = double(:result, values: lines) - - allow(query).to receive(:raw_explain).and_return(result) - - expect(query.explain).to eq(<<-EOF.strip) -Aggregate (cost=123 rows=1) - -> Index Only Scan using index_cats_are_amazing - EOF - end - end -end diff --git a/spec/lib/gitlab/sherlock/transaction_spec.rb b/spec/lib/gitlab/sherlock/transaction_spec.rb deleted file mode 100644 index 535b0ad4d8a..00000000000 --- a/spec/lib/gitlab/sherlock/transaction_spec.rb +++ /dev/null @@ -1,238 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Sherlock::Transaction do - let(:transaction) { described_class.new('POST', '/cat_pictures') } - - describe '#id' do - it 'returns the transaction ID' do - expect(transaction.id).to be_an_instance_of(String) - end - end - - describe '#type' do - it 'returns the type' do - expect(transaction.type).to eq('POST') - end - end - - describe '#path' do - it 'returns the path' do - expect(transaction.path).to eq('/cat_pictures') - end - end - - describe '#queries' do - it 'returns an Array of queries' do - expect(transaction.queries).to be_an_instance_of(Array) - end - end - - describe '#file_samples' do - it 'returns an Array of file samples' do - expect(transaction.file_samples).to be_an_instance_of(Array) - end - end - - describe '#started_at' do - it 'returns the start time' do - allow(transaction).to receive(:profile_lines).and_yield - - transaction.run { 'cats are amazing' } - - expect(transaction.started_at).to be_an_instance_of(Time) - end - end - - describe '#finished_at' do - it 'returns the completion time' do - allow(transaction).to receive(:profile_lines).and_yield - - transaction.run { 'cats are amazing' } - - expect(transaction.finished_at).to be_an_instance_of(Time) - end - end - - describe '#view_counts' do - it 'returns a Hash' do - expect(transaction.view_counts).to be_an_instance_of(Hash) - end - - it 'sets the default value of a key to 0' do - expect(transaction.view_counts['cats.rb']).to be_zero - end - end - - describe '#run' do - it 'runs the transaction' do - allow(transaction).to receive(:profile_lines).and_yield - - retval = transaction.run { 'cats are amazing' } - - expect(retval).to eq('cats are amazing') - end - end - - describe '#duration' do - it 'returns the duration in seconds' do - start_time = Time.now - - allow(transaction).to receive(:started_at).and_return(start_time) - allow(transaction).to receive(:finished_at).and_return(start_time + 5) - - expect(transaction.duration).to be_within(0.1).of(5.0) - end - end - - describe '#query_duration' do - it 'returns the total query duration in seconds' do - time = Time.now - query1 = Gitlab::Sherlock::Query.new('SELECT 1', time, time + 5) - query2 = Gitlab::Sherlock::Query.new('SELECT 2', time, time + 2) - - transaction.queries << query1 - transaction.queries << query2 - - expect(transaction.query_duration).to be_within(0.1).of(7.0) - end - end - - describe '#to_param' do - it 'returns the transaction ID' do - expect(transaction.to_param).to eq(transaction.id) - end - end - - describe '#sorted_queries' do - it 'returns the queries in descending order' do - start_time = Time.now - - query1 = Gitlab::Sherlock::Query.new('SELECT 1', start_time, start_time) - - query2 = Gitlab::Sherlock::Query - .new('SELECT 2', start_time, start_time + 5) - - transaction.queries << query1 - transaction.queries << query2 - - expect(transaction.sorted_queries).to eq([query2, query1]) - end - end - - describe '#sorted_file_samples' do - it 'returns the file samples in descending order' do - sample1 = Gitlab::Sherlock::FileSample.new(__FILE__, [], 10.0, 1) - sample2 = Gitlab::Sherlock::FileSample.new(__FILE__, [], 15.0, 1) - - transaction.file_samples << sample1 - transaction.file_samples << sample2 - - expect(transaction.sorted_file_samples).to eq([sample2, sample1]) - end - end - - describe '#find_query' do - it 'returns a Query when found' do - query = Gitlab::Sherlock::Query.new('SELECT 1', Time.now, Time.now) - - transaction.queries << query - - expect(transaction.find_query(query.id)).to eq(query) - end - - it 'returns nil when no query could be found' do - expect(transaction.find_query('cats')).to be_nil - end - end - - describe '#find_file_sample' do - it 'returns a FileSample when found' do - sample = Gitlab::Sherlock::FileSample.new(__FILE__, [], 10.0, 1) - - transaction.file_samples << sample - - expect(transaction.find_file_sample(sample.id)).to eq(sample) - end - - it 'returns nil when no file sample could be found' do - expect(transaction.find_file_sample('cats')).to be_nil - end - end - - describe '#profile_lines' do - describe 'when line profiling is enabled' do - it 'yields the block using the line profiler' do - allow(Gitlab::Sherlock).to receive(:enable_line_profiler?) - .and_return(true) - - allow_next_instance_of(Gitlab::Sherlock::LineProfiler) do |instance| - allow(instance).to receive(:profile).and_return('cats are amazing', []) - end - - retval = transaction.profile_lines { 'cats are amazing' } - - expect(retval).to eq('cats are amazing') - end - end - - describe 'when line profiling is disabled' do - it 'yields the block' do - allow(Gitlab::Sherlock).to receive(:enable_line_profiler?) - .and_return(false) - - retval = transaction.profile_lines { 'cats are amazing' } - - expect(retval).to eq('cats are amazing') - end - end - end - - describe '#subscribe_to_active_record' do - let(:subscription) { transaction.subscribe_to_active_record } - let(:time) { Time.now } - let(:query_data) { { sql: 'SELECT 1', binds: [] } } - - after do - ActiveSupport::Notifications.unsubscribe(subscription) - end - - it 'tracks executed queries' do - expect(transaction).to receive(:track_query) - .with('SELECT 1', [], time, time) - - subscription.publish('test', time, time, nil, query_data) - end - - it 'only tracks queries triggered from the transaction thread' do - expect(transaction).not_to receive(:track_query) - - Thread.new { subscription.publish('test', time, time, nil, query_data) } - .join - end - end - - describe '#subscribe_to_action_view' do - let(:subscription) { transaction.subscribe_to_action_view } - let(:time) { Time.now } - let(:view_data) { { identifier: 'foo.rb' } } - - after do - ActiveSupport::Notifications.unsubscribe(subscription) - end - - it 'tracks rendered views' do - expect(transaction).to receive(:track_view).with('foo.rb') - - subscription.publish('test', time, time, nil, view_data) - end - - it 'only tracks views rendered from the transaction thread' do - expect(transaction).not_to receive(:track_view) - - Thread.new { subscription.publish('test', time, time, nil, view_data) } - .join - end - end -end diff --git a/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb b/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb index 2f2499753b9..9affc3d5146 100644 --- a/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb +++ b/spec/lib/gitlab/sidekiq_status/client_middleware_spec.rb @@ -2,11 +2,11 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::SidekiqStatus::ClientMiddleware do +RSpec.describe Gitlab::SidekiqStatus::ClientMiddleware, :clean_gitlab_redis_queues do describe '#call' do context 'when the job has status_expiration set' do - it 'tracks the job in Redis with a value of 2' do - expect(Gitlab::SidekiqStatus).to receive(:set).with('123', 1.hour.to_i, value: 2) + it 'tracks the job in Redis' do + expect(Gitlab::SidekiqStatus).to receive(:set).with('123', 1.hour.to_i) described_class.new .call('Foo', { 'jid' => '123', 'status_expiration' => 1.hour.to_i }, double(:queue), double(:pool)) { nil } @@ -14,8 +14,8 @@ RSpec.describe Gitlab::SidekiqStatus::ClientMiddleware do end context 'when the job does not have status_expiration set' do - it 'tracks the job in Redis with a value of 1' do - expect(Gitlab::SidekiqStatus).to receive(:set).with('123', Gitlab::SidekiqStatus::DEFAULT_EXPIRATION, value: 1) + it 'does not track the job in Redis' do + expect(Gitlab::SidekiqStatus).to receive(:set).with('123', nil) described_class.new .call('Foo', { 'jid' => '123' }, double(:queue), double(:pool)) { nil } diff --git a/spec/lib/gitlab/sidekiq_status_spec.rb b/spec/lib/gitlab/sidekiq_status_spec.rb index 1e7b52471b0..c94deb8e008 100644 --- a/spec/lib/gitlab/sidekiq_status_spec.rb +++ b/spec/lib/gitlab/sidekiq_status_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Gitlab::SidekiqStatus, :clean_gitlab_redis_queues, :clean_gitlab_ Sidekiq.redis do |redis| expect(redis.exists(key)).to eq(true) expect(redis.ttl(key) > 0).to eq(true) - expect(redis.get(key)).to eq(described_class::DEFAULT_VALUE.to_s) + expect(redis.get(key)).to eq('1') end end @@ -24,19 +24,17 @@ RSpec.describe Gitlab::SidekiqStatus, :clean_gitlab_redis_queues, :clean_gitlab_ Sidekiq.redis do |redis| expect(redis.exists(key)).to eq(true) expect(redis.ttl(key) > described_class::DEFAULT_EXPIRATION).to eq(true) - expect(redis.get(key)).to eq(described_class::DEFAULT_VALUE.to_s) + expect(redis.get(key)).to eq('1') end end - it 'allows overriding the default value' do - described_class.set('123', value: 2) + it 'does not store anything with a nil expiry' do + described_class.set('123', nil) key = described_class.key_for('123') Sidekiq.redis do |redis| - expect(redis.exists(key)).to eq(true) - expect(redis.ttl(key) > 0).to eq(true) - expect(redis.get(key)).to eq('2') + expect(redis.exists(key)).to eq(false) end end end @@ -138,33 +136,5 @@ RSpec.describe Gitlab::SidekiqStatus, :clean_gitlab_redis_queues, :clean_gitlab_ it 'handles an empty array' do expect(described_class.job_status([])).to eq([]) end - - context 'when log_implicit_sidekiq_status_calls is enabled' do - it 'logs keys that contained the default value' do - described_class.set('123', value: 2) - described_class.set('456') - described_class.set('012') - - expect(Sidekiq.logger).to receive(:info).with(message: described_class::DEFAULT_VALUE_MESSAGE, - keys: [described_class.key_for('456'), described_class.key_for('012')]) - - expect(described_class.job_status(%w(123 456 789 012))).to eq([true, true, false, true]) - end - end - - context 'when log_implicit_sidekiq_status_calls is disabled' do - before do - stub_feature_flags(log_implicit_sidekiq_status_calls: false) - end - - it 'does not perform any logging' do - described_class.set('123', value: 2) - described_class.set('456') - - expect(Sidekiq.logger).not_to receive(:info) - - expect(described_class.job_status(%w(123 456 789))).to eq([true, true, false]) - end - end end end diff --git a/spec/lib/gitlab/sourcegraph_spec.rb b/spec/lib/gitlab/sourcegraph_spec.rb index 6bebd1ca3e6..e2c1e959cbf 100644 --- a/spec/lib/gitlab/sourcegraph_spec.rb +++ b/spec/lib/gitlab/sourcegraph_spec.rb @@ -37,6 +37,12 @@ RSpec.describe Gitlab::Sourcegraph do it { is_expected.to be_truthy } end + + context 'when feature is disabled' do + let(:feature_scope) { false } + + it { is_expected.to be_falsey } + end end describe '.feature_enabled?' do diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb index e1a588a4b7d..38486b313cb 100644 --- a/spec/lib/gitlab/ssh_public_key_spec.rb +++ b/spec/lib/gitlab/ssh_public_key_spec.rb @@ -21,6 +21,14 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do end end + describe '.supported_types' do + it 'returns array with the names of supported technologies' do + expect(described_class.supported_types).to eq( + [:rsa, :dsa, :ecdsa, :ed25519] + ) + end + end + describe '.supported_sizes(name)' do where(:name, :sizes) do [ @@ -31,14 +39,43 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do ] end - subject { described_class.supported_sizes(name) } - with_them do it { expect(described_class.supported_sizes(name)).to eq(sizes) } it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) } end end + describe '.supported_algorithms' do + it 'returns all supported algorithms' do + expect(described_class.supported_algorithms).to eq( + %w( + ssh-rsa + ssh-dss + ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 + ssh-ed25519 + ) + ) + end + end + + describe '.supported_algorithms_for_name' do + where(:name, :algorithms) do + [ + [:rsa, %w(ssh-rsa)], + [:dsa, %w(ssh-dss)], + [:ecdsa, %w(ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521)], + [:ed25519, %w(ssh-ed25519)] + ] + end + + with_them do + it "returns all supported algorithms for #{params[:name]}" do + expect(described_class.supported_algorithms_for_name(name)).to eq(algorithms) + expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms) + end + end + end + describe '.sanitize(key_content)' do let(:content) { build(:key).key } diff --git a/spec/lib/gitlab/themes_spec.rb b/spec/lib/gitlab/themes_spec.rb index 6d03cf496b8..c9dc23d7c14 100644 --- a/spec/lib/gitlab/themes_spec.rb +++ b/spec/lib/gitlab/themes_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Gitlab::Themes, lib: true do it 'prevents an infinite loop when configuration default is invalid' do default = described_class::APPLICATION_DEFAULT - themes = described_class::THEMES + themes = described_class.available_themes config = double(default_theme: 0).as_null_object allow(Gitlab).to receive(:config).and_return(config) diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb index 7d678db5ec8..c88b0af30f6 100644 --- a/spec/lib/gitlab/tracking/standard_context_spec.rb +++ b/spec/lib/gitlab/tracking/standard_context_spec.rb @@ -58,6 +58,10 @@ RSpec.describe Gitlab::Tracking::StandardContext do expect(snowplow_context.to_json.dig(:data, :source)).to eq(described_class::GITLAB_RAILS_SOURCE) end + it 'contains context_generated_at timestamp', :freeze_time do + expect(snowplow_context.to_json.dig(:data, :context_generated_at)).to eq(Time.current) + end + context 'plan' do context 'when namespace is not available' do it 'is nil' do diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb index 0a32bdb95d3..4d84423cde4 100644 --- a/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb +++ b/spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::DatabaseMetric do let_it_be(:issues) { Issue.all } before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow(Issue.connection).to receive(:transaction_open?).and_return(false) end it 'calculates a correct result' do @@ -82,7 +82,7 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::DatabaseMetric do end.new(time_frame: 'all') end - it 'calculates a correct result' do + it 'calculates a correct result', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/349762' do expect(subject.value).to be_within(Gitlab::Database::PostgresHll::BatchDistinctCounter::ERROR_RATE).percent_of(3) end diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb index c8cb1bb4373..cc4df696b37 100644 --- a/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb +++ b/spec/lib/gitlab/usage/metrics/instrumentations/generic_metric_spec.rb @@ -17,9 +17,25 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::GenericMetric do end context 'when raising an exception' do - it 'return the custom fallback' do + before do + allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(should_raise_for_dev) expect(ApplicationRecord.database).to receive(:version).and_raise('Error') - expect(subject.value).to eq(custom_fallback) + end + + context 'with should_raise_for_dev? false' do + let(:should_raise_for_dev) { false } + + it 'return the custom fallback' do + expect(subject.value).to eq(custom_fallback) + end + end + + context 'with should_raise_for_dev? true' do + let(:should_raise_for_dev) { true } + + it 'raises an error' do + expect { subject.value }.to raise_error('Error') + end end end end @@ -38,9 +54,25 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::GenericMetric do end context 'when raising an exception' do - it 'return the default fallback' do + before do + allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(should_raise_for_dev) expect(ApplicationRecord.database).to receive(:version).and_raise('Error') - expect(subject.value).to eq(described_class::FALLBACK) + end + + context 'with should_raise_for_dev? false' do + let(:should_raise_for_dev) { false } + + it 'return the default fallback' do + expect(subject.value).to eq(described_class::FALLBACK) + end + end + + context 'with should_raise_for_dev? true' do + let(:should_raise_for_dev) { true } + + it 'raises an error' do + expect { subject.value }.to raise_error('Error') + end end end end diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb index 0ec805714e3..f7ff68af8a2 100644 --- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb @@ -48,7 +48,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s 'epic_boards_usage', 'secure', 'importer', - 'network_policies' + 'network_policies', + 'geo' ) end end diff --git a/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb index 6f201b43390..1ac344d9250 100644 --- a/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/package_event_counter_spec.rb @@ -13,10 +13,6 @@ RSpec.describe Gitlab::UsageDataCounters::PackageEventCounter, :clean_gitlab_red end end - it 'includes the right events' do - expect(described_class::KNOWN_EVENTS.size).to eq 63 - end - described_class::KNOWN_EVENTS.each do |event| it_behaves_like 'usage counter with totals', event end @@ -24,8 +20,8 @@ RSpec.describe Gitlab::UsageDataCounters::PackageEventCounter, :clean_gitlab_red describe '.fetch_supported_event' do subject { described_class.fetch_supported_event(event_name) } - let(:event_name) { 'package_events_i_package_composer_push_package' } + let(:event_name) { 'package_events_i_package_conan_push_package' } - it { is_expected.to eq 'i_package_composer_push_package' } + it { is_expected.to eq 'i_package_conan_push_package' } end end diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb index 64eff76a9f2..a8cf87d9364 100644 --- a/spec/lib/gitlab/usage_data_queries_spec.rb +++ b/spec/lib/gitlab/usage_data_queries_spec.rb @@ -3,10 +3,6 @@ require 'spec_helper' RSpec.describe Gitlab::UsageDataQueries do - before do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) - end - describe '#add_metric' do let(:metric) { 'CountBoardsMetric' } diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 015ecd1671e..427e8e67090 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do stub_usage_data_connections stub_object_store_settings clear_memoized_values(described_class::CE_MEMOIZED_VALUES) + stub_database_flavor_check('Cloud SQL for PostgreSQL') end describe '.uncached_data' do @@ -160,7 +161,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do another_project = create(:project, :repository, creator: another_user) create(:remote_mirror, project: another_project, enabled: false) create(:snippet, author: user) - create(:suggestion, note: create(:note, project: project)) end expect(described_class.usage_activity_by_stage_create({})).to include( @@ -170,8 +170,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_with_disable_overriding_approvers_per_merge_request: 2, projects_without_disable_overriding_approvers_per_merge_request: 6, remote_mirrors: 2, - snippets: 2, - suggestions: 2 + snippets: 2 ) expect(described_class.usage_activity_by_stage_create(described_class.monthly_time_range_db_params)).to include( deploy_keys: 1, @@ -180,8 +179,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do projects_with_disable_overriding_approvers_per_merge_request: 1, projects_without_disable_overriding_approvers_per_merge_request: 3, remote_mirrors: 1, - snippets: 1, - suggestions: 1 + snippets: 1 ) end end @@ -278,8 +276,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(described_class.usage_activity_by_stage_manage({})).to include( { bulk_imports: { - gitlab_v1: 2, - gitlab: Gitlab::UsageData::DEPRECATED_VALUE + gitlab_v1: 2 }, project_imports: { bitbucket: 2, @@ -302,32 +299,13 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do group_imports: { group_import: 2, gitlab_migration: 2 - }, - projects_imported: { - total: Gitlab::UsageData::DEPRECATED_VALUE, - gitlab_project: Gitlab::UsageData::DEPRECATED_VALUE, - gitlab: Gitlab::UsageData::DEPRECATED_VALUE, - github: Gitlab::UsageData::DEPRECATED_VALUE, - bitbucket: Gitlab::UsageData::DEPRECATED_VALUE, - bitbucket_server: Gitlab::UsageData::DEPRECATED_VALUE, - gitea: Gitlab::UsageData::DEPRECATED_VALUE, - git: Gitlab::UsageData::DEPRECATED_VALUE, - manifest: Gitlab::UsageData::DEPRECATED_VALUE - }, - issues_imported: { - jira: Gitlab::UsageData::DEPRECATED_VALUE, - fogbugz: Gitlab::UsageData::DEPRECATED_VALUE, - phabricator: Gitlab::UsageData::DEPRECATED_VALUE, - csv: Gitlab::UsageData::DEPRECATED_VALUE - }, - groups_imported: Gitlab::UsageData::DEPRECATED_VALUE + } } ) expect(described_class.usage_activity_by_stage_manage(described_class.monthly_time_range_db_params)).to include( { bulk_imports: { - gitlab_v1: 1, - gitlab: Gitlab::UsageData::DEPRECATED_VALUE + gitlab_v1: 1 }, project_imports: { bitbucket: 1, @@ -350,25 +328,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do group_imports: { group_import: 1, gitlab_migration: 1 - }, - projects_imported: { - total: Gitlab::UsageData::DEPRECATED_VALUE, - gitlab_project: Gitlab::UsageData::DEPRECATED_VALUE, - gitlab: Gitlab::UsageData::DEPRECATED_VALUE, - github: Gitlab::UsageData::DEPRECATED_VALUE, - bitbucket: Gitlab::UsageData::DEPRECATED_VALUE, - bitbucket_server: Gitlab::UsageData::DEPRECATED_VALUE, - gitea: Gitlab::UsageData::DEPRECATED_VALUE, - git: Gitlab::UsageData::DEPRECATED_VALUE, - manifest: Gitlab::UsageData::DEPRECATED_VALUE - }, - issues_imported: { - jira: Gitlab::UsageData::DEPRECATED_VALUE, - fogbugz: Gitlab::UsageData::DEPRECATED_VALUE, - phabricator: Gitlab::UsageData::DEPRECATED_VALUE, - csv: Gitlab::UsageData::DEPRECATED_VALUE - }, - groups_imported: Gitlab::UsageData::DEPRECATED_VALUE + } } ) end @@ -920,6 +880,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject[:database][:adapter]).to eq(ApplicationRecord.database.adapter_name) expect(subject[:database][:version]).to eq(ApplicationRecord.database.version) expect(subject[:database][:pg_system_id]).to eq(ApplicationRecord.database.system_id) + expect(subject[:database][:flavor]).to eq('Cloud SQL for PostgreSQL') expect(subject[:mail][:smtp_server]).to eq(ActionMailer::Base.smtp_settings[:address]) expect(subject[:gitaly][:version]).to be_present expect(subject[:gitaly][:servers]).to be >= 1 @@ -964,10 +925,25 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do end context 'when retrieve component setting meets exception' do - it 'returns -1 for component enable status' do + before do + allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(should_raise_for_dev) allow(Settings).to receive(:[]).with(component).and_raise(StandardError) + end + + context 'with should_raise_for_dev? false' do + let(:should_raise_for_dev) { false } + + it 'returns -1 for component enable status' do + expect(subject).to eq({ enabled: -1 }) + end + end + + context 'with should_raise_for_dev? true' do + let(:should_raise_for_dev) { true } - expect(subject).to eq({ enabled: -1 }) + it 'raises an error' do + expect { subject.value }.to raise_error(StandardError) + end end end end @@ -1328,6 +1304,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories } + let(:ignored_metrics) { ["i_package_composer_deploy_token_weekly"] } + it 'has all known_events' do expect(subject).to have_key(:redis_hll_counters) @@ -1337,6 +1315,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do keys = ::Gitlab::UsageDataCounters::HLLRedisCounter.events_for_category(category) metrics = keys.map { |key| "#{key}_weekly" } + keys.map { |key| "#{key}_monthly" } + metrics -= ignored_metrics if ::Gitlab::UsageDataCounters::HLLRedisCounter::CATEGORIES_FOR_TOTALS.include?(category) metrics.append("#{category}_total_unique_counts_weekly", "#{category}_total_unique_counts_monthly") diff --git a/spec/lib/gitlab/utils/usage_data_spec.rb b/spec/lib/gitlab/utils/usage_data_spec.rb index 325ace6fbbf..b44c6565538 100644 --- a/spec/lib/gitlab/utils/usage_data_spec.rb +++ b/spec/lib/gitlab/utils/usage_data_spec.rb @@ -5,11 +5,13 @@ require 'spec_helper' RSpec.describe Gitlab::Utils::UsageData do include Database::DatabaseHelpers - shared_examples 'failing hardening method' do + shared_examples 'failing hardening method' do |raised_exception| + let(:exception) { raised_exception || ActiveRecord::StatementInvalid } + before do allow(Gitlab::ErrorTracking).to receive(:should_raise_for_dev?).and_return(should_raise_for_dev) stub_const("Gitlab::Utils::UsageData::FALLBACK", fallback) - allow(failing_class).to receive(failing_method).and_raise(ActiveRecord::StatementInvalid) + allow(failing_class).to receive(failing_method).and_raise(exception) unless failing_class.nil? end context 'with should_raise_for_dev? false' do @@ -24,7 +26,7 @@ RSpec.describe Gitlab::Utils::UsageData do let(:should_raise_for_dev) { true } it 'raises an error' do - expect { subject }.to raise_error(ActiveRecord::StatementInvalid) + expect { subject }.to raise_error(exception) end end end @@ -366,8 +368,13 @@ RSpec.describe Gitlab::Utils::UsageData do expect(described_class.add).to eq(0) end - it 'returns the fallback value when adding fails' do - expect(described_class.add(nil, 3)).to eq(-1) + context 'when adding fails' do + subject { described_class.add(nil, 3) } + + let(:fallback) { -1 } + let(:failing_class) { nil } + + it_behaves_like 'failing hardening method', StandardError end it 'returns the fallback value one of the arguments is negative' do @@ -376,8 +383,13 @@ RSpec.describe Gitlab::Utils::UsageData do end describe '#alt_usage_data' do - it 'returns the fallback when it gets an error' do - expect(described_class.alt_usage_data { raise StandardError } ).to eq(-1) + context 'when method fails' do + subject { described_class.alt_usage_data { raise StandardError } } + + let(:fallback) { -1 } + let(:failing_class) { nil } + + it_behaves_like 'failing hardening method', StandardError end it 'returns the evaluated block when give' do @@ -391,14 +403,22 @@ RSpec.describe Gitlab::Utils::UsageData do describe '#redis_usage_data' do context 'with block given' do - it 'returns the fallback when it gets an error' do - expect(described_class.redis_usage_data { raise ::Redis::CommandError } ).to eq(-1) + context 'when method fails' do + subject { described_class.redis_usage_data { raise ::Redis::CommandError } } + + let(:fallback) { -1 } + let(:failing_class) { nil } + + it_behaves_like 'failing hardening method', ::Redis::CommandError end - it 'returns the fallback when Redis HLL raises any error' do - stub_const("Gitlab::Utils::UsageData::FALLBACK", 15) + context 'when Redis HLL raises any error' do + subject { described_class.redis_usage_data { raise Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch } } + + let(:fallback) { 15 } + let(:failing_class) { nil } - expect(described_class.redis_usage_data { raise Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch } ).to eq(15) + it_behaves_like 'failing hardening method', Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch end it 'returns the evaluated block when given' do @@ -407,9 +427,14 @@ RSpec.describe Gitlab::Utils::UsageData do end context 'with counter given' do - it 'returns the falback values for all counter keys when it gets an error' do - allow(::Gitlab::UsageDataCounters::WikiPageCounter).to receive(:totals).and_raise(::Redis::CommandError) - expect(described_class.redis_usage_data(::Gitlab::UsageDataCounters::WikiPageCounter)).to eql(::Gitlab::UsageDataCounters::WikiPageCounter.fallback_totals) + context 'when gets an error' do + subject { described_class.redis_usage_data(::Gitlab::UsageDataCounters::WikiPageCounter) } + + let(:fallback) { ::Gitlab::UsageDataCounters::WikiPageCounter.fallback_totals } + let(:failing_class) { ::Gitlab::UsageDataCounters::WikiPageCounter } + let(:failing_method) { :totals } + + it_behaves_like 'failing hardening method', ::Redis::CommandError end it 'returns the totals when couter is given' do diff --git a/spec/lib/gitlab/web_hooks/recursion_detection_spec.rb b/spec/lib/gitlab/web_hooks/recursion_detection_spec.rb new file mode 100644 index 00000000000..45170864967 --- /dev/null +++ b/spec/lib/gitlab/web_hooks/recursion_detection_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WebHooks::RecursionDetection, :clean_gitlab_redis_shared_state, :request_store do + let_it_be(:web_hook) { create(:project_hook) } + + let!(:uuid_class) { described_class::UUID } + + describe '.set_from_headers' do + let(:old_uuid) { SecureRandom.uuid } + let(:rack_headers) { Rack::MockRequest.env_for("/").merge(headers) } + + subject(:set_from_headers) { described_class.set_from_headers(rack_headers) } + + # Note, having a previous `request_uuid` value set before `.set_from_headers` is + # called is contrived and should not normally happen. However, testing with this scenario + # allows us to assert the ideal outcome if it ever were to happen. + before do + uuid_class.instance.request_uuid = old_uuid + end + + context 'when the detection header is present' do + let(:new_uuid) { SecureRandom.uuid } + + let(:headers) do + { uuid_class::HEADER => new_uuid } + end + + it 'sets the request UUID value from the headers' do + set_from_headers + + expect(uuid_class.instance.request_uuid).to eq(new_uuid) + end + end + + context 'when detection header is not present' do + let(:headers) { {} } + + it 'does not set the request UUID' do + set_from_headers + + expect(uuid_class.instance.request_uuid).to eq(old_uuid) + end + end + end + + describe '.set_request_uuid' do + it 'sets the request UUID value' do + new_uuid = SecureRandom.uuid + + described_class.set_request_uuid(new_uuid) + + expect(uuid_class.instance.request_uuid).to eq(new_uuid) + end + end + + describe '.register!' do + let_it_be(:second_web_hook) { create(:project_hook) } + let_it_be(:third_web_hook) { create(:project_hook) } + + def cache_key(hook) + described_class.send(:cache_key_for_hook, hook) + end + + it 'stores IDs in the same cache when a request UUID is set, until the request UUID changes', :aggregate_failures do + # Register web_hook and second_web_hook against the same request UUID. + uuid_class.instance.request_uuid = SecureRandom.uuid + described_class.register!(web_hook) + described_class.register!(second_web_hook) + first_cache_key = cache_key(web_hook) + second_cache_key = cache_key(second_web_hook) + + # Register third_web_hook against a new request UUID. + uuid_class.instance.request_uuid = SecureRandom.uuid + described_class.register!(third_web_hook) + third_cache_key = cache_key(third_web_hook) + + expect(first_cache_key).to eq(second_cache_key) + expect(second_cache_key).not_to eq(third_cache_key) + + ::Gitlab::Redis::SharedState.with do |redis| + members = redis.smembers(first_cache_key).map(&:to_i) + expect(members).to contain_exactly(web_hook.id, second_web_hook.id) + + members = redis.smembers(third_cache_key).map(&:to_i) + expect(members).to contain_exactly(third_web_hook.id) + end + end + + it 'stores IDs in unique caches when no request UUID is present', :aggregate_failures do + described_class.register!(web_hook) + described_class.register!(second_web_hook) + described_class.register!(third_web_hook) + + first_cache_key = cache_key(web_hook) + second_cache_key = cache_key(second_web_hook) + third_cache_key = cache_key(third_web_hook) + + expect([first_cache_key, second_cache_key, third_cache_key].compact.length).to eq(3) + + ::Gitlab::Redis::SharedState.with do |redis| + members = redis.smembers(first_cache_key).map(&:to_i) + expect(members).to contain_exactly(web_hook.id) + + members = redis.smembers(second_cache_key).map(&:to_i) + expect(members).to contain_exactly(second_web_hook.id) + + members = redis.smembers(third_cache_key).map(&:to_i) + expect(members).to contain_exactly(third_web_hook.id) + end + end + + it 'touches the storage ttl each time it is called', :aggregate_failures do + freeze_time do + described_class.register!(web_hook) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(cache_key(web_hook))).to eq(described_class::TOUCH_CACHE_TTL.to_i) + end + end + + travel_to(1.minute.from_now) do + described_class.register!(second_web_hook) + + ::Gitlab::Redis::SharedState.with do |redis| + expect(redis.ttl(cache_key(web_hook))).to eq(described_class::TOUCH_CACHE_TTL.to_i) + end + end + end + end + + describe 'block?' do + let_it_be(:registered_web_hooks) { create_list(:project_hook, 2) } + + subject(:block?) { described_class.block?(web_hook) } + + before do + # Register some previous webhooks. + uuid_class.instance.request_uuid = SecureRandom.uuid + + registered_web_hooks.each do |web_hook| + described_class.register!(web_hook) + end + end + + it 'returns false if webhook should not be blocked' do + is_expected.to eq(false) + end + + context 'when the webhook has previously fired' do + before do + described_class.register!(web_hook) + end + + it 'returns true' do + is_expected.to eq(true) + end + + context 'when the request UUID changes again' do + before do + uuid_class.instance.request_uuid = SecureRandom.uuid + end + + it 'returns false' do + is_expected.to eq(false) + end + end + end + + context 'when the count limit has been reached' do + let_it_be(:registered_web_hooks) { create_list(:project_hook, 2) } + + before do + registered_web_hooks.each do |web_hook| + described_class.register!(web_hook) + end + + stub_const("#{described_class.name}::COUNT_LIMIT", registered_web_hooks.size) + end + + it 'returns true' do + is_expected.to eq(true) + end + + context 'when the request UUID changes again' do + before do + uuid_class.instance.request_uuid = SecureRandom.uuid + end + + it 'returns false' do + is_expected.to eq(false) + end + end + end + end + + describe '.header' do + subject(:header) { described_class.header(web_hook) } + + it 'returns a header with the UUID value' do + uuid = SecureRandom.uuid + allow(uuid_class.instance).to receive(:uuid_for_hook).and_return(uuid) + + is_expected.to eq({ uuid_class::HEADER => uuid }) + end + end + + describe '.to_log' do + subject(:to_log) { described_class.to_log(web_hook) } + + it 'returns the UUID value and all registered webhook IDs in a Hash' do + uuid = SecureRandom.uuid + allow(uuid_class.instance).to receive(:uuid_for_hook).and_return(uuid) + registered_web_hooks = create_list(:project_hook, 2) + registered_web_hooks.each { described_class.register!(_1) } + + is_expected.to eq({ uuid: uuid, ids: registered_web_hooks.map(&:id) }) + end + end +end diff --git a/spec/lib/gitlab_edition_spec.rb b/spec/lib/gitlab_edition_spec.rb new file mode 100644 index 00000000000..2f1316819ec --- /dev/null +++ b/spec/lib/gitlab_edition_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabEdition do + before do + # Make sure the ENV is clean + stub_env('FOSS_ONLY', nil) + stub_env('EE_ONLY', nil) + + described_class.instance_variable_set(:@is_ee, nil) + described_class.instance_variable_set(:@is_jh, nil) + end + + after do + described_class.instance_variable_set(:@is_ee, nil) + described_class.instance_variable_set(:@is_jh, nil) + end + + describe '.root' do + it 'returns the root path of the app' do + expect(described_class.root).to eq(Pathname.new(File.expand_path('../..', __dir__))) + end + end + + describe 'extensions' do + context 'when .jh? is true' do + before do + allow(described_class).to receive(:jh?).and_return(true) + end + + it 'returns %w[ee jh]' do + expect(described_class.extensions).to match_array(%w[ee jh]) + end + end + + context 'when .ee? is true' do + before do + allow(described_class).to receive(:jh?).and_return(false) + allow(described_class).to receive(:ee?).and_return(true) + end + + it 'returns %w[ee]' do + expect(described_class.extensions).to match_array(%w[ee]) + end + end + + context 'when neither .jh? and .ee? are true' do + before do + allow(described_class).to receive(:jh?).and_return(false) + allow(described_class).to receive(:ee?).and_return(false) + end + + it 'returns the exyensions according to the current edition' do + expect(described_class.extensions).to be_empty + end + end + end + + describe '.ee? and .jh?' do + def stub_path(*paths, **arguments) + root = Pathname.new('dummy') + pathname = double(:path, **arguments) + + allow(described_class) + .to receive(:root) + .and_return(root) + + allow(root).to receive(:join) + + paths.each do |path| + allow(root) + .to receive(:join) + .with(path) + .and_return(pathname) + end + end + + describe '.ee?' do + context 'for EE' do + before do + stub_path('ee/app/models/license.rb', exist?: true) + end + + context 'when using FOSS_ONLY=1' do + before do + stub_env('FOSS_ONLY', '1') + end + + it 'returns not to be EE' do + expect(described_class).not_to be_ee + end + end + + context 'when using FOSS_ONLY=0' do + before do + stub_env('FOSS_ONLY', '0') + end + + it 'returns to be EE' do + expect(described_class).to be_ee + end + end + + context 'when using default FOSS_ONLY' do + it 'returns to be EE' do + expect(described_class).to be_ee + end + end + end + + context 'for CE' do + before do + stub_path('ee/app/models/license.rb', exist?: false) + end + + it 'returns not to be EE' do + expect(described_class).not_to be_ee + end + end + end + + describe '.jh?' do + context 'for JH' do + before do + stub_path( + 'ee/app/models/license.rb', + 'jh', + exist?: true) + end + + context 'when using default FOSS_ONLY and EE_ONLY' do + it 'returns to be JH' do + expect(described_class).to be_jh + end + end + + context 'when using FOSS_ONLY=1' do + before do + stub_env('FOSS_ONLY', '1') + end + + it 'returns not to be JH' do + expect(described_class).not_to be_jh + end + end + + context 'when using EE_ONLY=1' do + before do + stub_env('EE_ONLY', '1') + end + + it 'returns not to be JH' do + expect(described_class).not_to be_jh + end + end + end + end + end +end diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb index 869eaf26772..49ba4debe31 100644 --- a/spec/lib/gitlab_spec.rb +++ b/spec/lib/gitlab_spec.rb @@ -3,9 +3,19 @@ require 'spec_helper' RSpec.describe Gitlab do - describe '.root' do - it 'returns the root path of the app' do - expect(described_class.root).to eq(Pathname.new(File.expand_path('../..', __dir__))) + %w[root extensions ee? jh?].each do |method_name| + it "delegates #{method_name} to GitlabEdition" do + expect(GitlabEdition).to receive(method_name) + + described_class.public_send(method_name) + end + end + + %w[ee jh].each do |method_name| + it "delegates #{method_name} to GitlabEdition" do + expect(GitlabEdition).to receive(method_name) + + described_class.public_send(method_name) {} end end @@ -248,121 +258,6 @@ RSpec.describe Gitlab do end end - describe 'ee? and jh?' do - before do - # Make sure the ENV is clean - stub_env('FOSS_ONLY', nil) - stub_env('EE_ONLY', nil) - - described_class.instance_variable_set(:@is_ee, nil) - described_class.instance_variable_set(:@is_jh, nil) - end - - after do - described_class.instance_variable_set(:@is_ee, nil) - described_class.instance_variable_set(:@is_jh, nil) - end - - def stub_path(*paths, **arguments) - root = Pathname.new('dummy') - pathname = double(:path, **arguments) - - allow(described_class) - .to receive(:root) - .and_return(root) - - allow(root).to receive(:join) - - paths.each do |path| - allow(root) - .to receive(:join) - .with(path) - .and_return(pathname) - end - end - - describe '.ee?' do - context 'for EE' do - before do - stub_path('ee/app/models/license.rb', exist?: true) - end - - context 'when using FOSS_ONLY=1' do - before do - stub_env('FOSS_ONLY', '1') - end - - it 'returns not to be EE' do - expect(described_class).not_to be_ee - end - end - - context 'when using FOSS_ONLY=0' do - before do - stub_env('FOSS_ONLY', '0') - end - - it 'returns to be EE' do - expect(described_class).to be_ee - end - end - - context 'when using default FOSS_ONLY' do - it 'returns to be EE' do - expect(described_class).to be_ee - end - end - end - - context 'for CE' do - before do - stub_path('ee/app/models/license.rb', exist?: false) - end - - it 'returns not to be EE' do - expect(described_class).not_to be_ee - end - end - end - - describe '.jh?' do - context 'for JH' do - before do - stub_path( - 'ee/app/models/license.rb', - 'jh', - exist?: true) - end - - context 'when using default FOSS_ONLY and EE_ONLY' do - it 'returns to be JH' do - expect(described_class).to be_jh - end - end - - context 'when using FOSS_ONLY=1' do - before do - stub_env('FOSS_ONLY', '1') - end - - it 'returns not to be JH' do - expect(described_class).not_to be_jh - end - end - - context 'when using EE_ONLY=1' do - before do - stub_env('EE_ONLY', '1') - end - - it 'returns not to be JH' do - expect(described_class).not_to be_jh - end - end - end - end - end - describe '.http_proxy_env?' do it 'returns true when lower case https' do stub_env('https_proxy', 'https://my.proxy') diff --git a/spec/lib/sidebars/groups/menus/settings_menu_spec.rb b/spec/lib/sidebars/groups/menus/settings_menu_spec.rb index 314c4cdc602..252da8ea699 100644 --- a/spec/lib/sidebars/groups/menus/settings_menu_spec.rb +++ b/spec/lib/sidebars/groups/menus/settings_menu_spec.rb @@ -56,6 +56,12 @@ RSpec.describe Sidebars::Groups::Menus::SettingsMenu do it_behaves_like 'access rights checks' end + describe 'Access Tokens' do + let(:item_id) { :access_tokens } + + it_behaves_like 'access rights checks' + end + describe 'Repository menu' do let(:item_id) { :repository } diff --git a/spec/lib/sidebars/projects/panel_spec.rb b/spec/lib/sidebars/projects/panel_spec.rb index 2e79ced7039..7e69a2dfe52 100644 --- a/spec/lib/sidebars/projects/panel_spec.rb +++ b/spec/lib/sidebars/projects/panel_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Sidebars::Projects::Panel do - let(:project) { build(:project) } + let_it_be(:project) { create(:project) } + let(:context) { Sidebars::Projects::Context.new(current_user: nil, container: project) } subject { described_class.new(context) } diff --git a/spec/lib/version_check_spec.rb b/spec/lib/version_check_spec.rb index d7a772a3f7e..736a8f9595e 100644 --- a/spec/lib/version_check_spec.rb +++ b/spec/lib/version_check_spec.rb @@ -3,12 +3,6 @@ require 'spec_helper' RSpec.describe VersionCheck do - describe '.image_url' do - it 'returns the correct URL' do - expect(described_class.image_url).to match(%r{\A#{Regexp.escape(described_class.host)}/check\.svg\?gitlab_info=\w+}) - end - end - describe '.url' do it 'returns the correct URL' do expect(described_class.url).to match(%r{\A#{Regexp.escape(described_class.host)}/check\.json\?gitlab_info=\w+}) diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 365ca892bb1..af77989dbbc 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -49,7 +49,7 @@ RSpec.describe Emails::Profile do describe 'for users that signed up, the email' do let(:example_site_path) { root_path } - let(:new_user) { create(:user, email: new_user_address, password: "securePassword") } + let(:new_user) { create(:user, email: new_user_address, password: Gitlab::Password.test_default) } subject { Notify.new_user_email(new_user.id) } diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 44cb18008d2..0fbdc09a206 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -213,7 +213,7 @@ RSpec.describe Notify do subject { described_class.issue_due_email(recipient.id, issue.id) } before do - issue.update(due_date: Date.tomorrow) + issue.update!(due_date: Date.tomorrow) end it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do @@ -1229,7 +1229,7 @@ RSpec.describe Notify do end context 'when a comment on an existing discussion' do - let(:first_note) { create(model) } + let(:first_note) { create(model) } # rubocop:disable Rails/SaveBang let(:note) { create(model, author: note_author, noteable: nil, in_reply_to: first_note) } it 'contains an introduction' do @@ -1505,7 +1505,7 @@ RSpec.describe Notify do context 'member is not created by a user' do before do - group_member.update(created_by: nil) + group_member.update!(created_by: nil) end it_behaves_like 'no email is sent' @@ -1513,7 +1513,7 @@ RSpec.describe Notify do context 'member is a known user' do before do - group_member.update(user: create(:user)) + group_member.update!(user: create(:user)) end it_behaves_like 'no email is sent' @@ -1737,7 +1737,7 @@ RSpec.describe Notify do stub_config_setting(email_subject_suffix: 'A Nice Suffix') perform_enqueued_jobs do user.email = "new-email@mail.com" - user.save + user.save! end end diff --git a/spec/metrics_server/metrics_server_spec.rb b/spec/metrics_server/metrics_server_spec.rb index 4e3c6900875..fc18df9b5cd 100644 --- a/spec/metrics_server/metrics_server_spec.rb +++ b/spec/metrics_server/metrics_server_spec.rb @@ -8,18 +8,32 @@ require_relative '../support/helpers/next_instance_of' RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath include NextInstanceOf + let(:prometheus_config) { ::Prometheus::Client.configuration } + let(:metrics_dir) { Dir.mktmpdir } + + # Prometheus::Client is a singleton, i.e. shared global state, so + # we need to reset it after testing. + let!(:old_multiprocess_files_dir) { prometheus_config.multiprocess_files_dir } + before do # We do not want this to have knock-on effects on the test process. allow(Gitlab::ProcessManagement).to receive(:modify_signals) end + after do + Gitlab::Metrics.reset_registry! + prometheus_config.multiprocess_files_dir = old_multiprocess_files_dir + + FileUtils.rm_rf(metrics_dir, secure: true) + end + describe '.spawn' do context 'when in parent process' do it 'forks into a new process and detaches it' do expect(Process).to receive(:fork).and_return(99) expect(Process).to receive(:detach).with(99) - described_class.spawn('sidekiq', metrics_dir: 'path/to/metrics') + described_class.spawn('sidekiq', metrics_dir: metrics_dir) end end @@ -35,13 +49,13 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath expect(server).to receive(:start) end - described_class.spawn('sidekiq', metrics_dir: 'path/to/metrics') + described_class.spawn('sidekiq', metrics_dir: metrics_dir) end it 'resets signal handlers from parent process' do expect(Gitlab::ProcessManagement).to receive(:modify_signals).with(%i[A B], 'DEFAULT') - described_class.spawn('sidekiq', metrics_dir: 'path/to/metrics', trapped_signals: %i[A B]) + described_class.spawn('sidekiq', metrics_dir: metrics_dir, trapped_signals: %i[A B]) end end end @@ -49,29 +63,27 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath describe '#start' do let(:exporter_class) { Class.new(Gitlab::Metrics::Exporter::BaseExporter) } let(:exporter_double) { double('fake_exporter', start: true) } - let(:prometheus_config) { ::Prometheus::Client.configuration } - let(:metrics_dir) { Dir.mktmpdir } let(:settings) { { "fake_exporter" => { "enabled" => true } } } - let!(:old_metrics_dir) { prometheus_config.multiprocess_files_dir } + let(:ruby_sampler_double) { double(Gitlab::Metrics::Samplers::RubySampler) } subject(:metrics_server) { described_class.new('fake', metrics_dir, true)} before do stub_const('Gitlab::Metrics::Exporter::FakeExporter', exporter_class) - expect(exporter_class).to receive(:instance).with(settings['fake_exporter'], synchronous: true).and_return(exporter_double) + expect(exporter_class).to receive(:instance).with( + settings['fake_exporter'], gc_requests: true, synchronous: true + ).and_return(exporter_double) expect(Settings).to receive(:monitoring).and_return(settings) - end - after do - Gitlab::Metrics.reset_registry! - FileUtils.rm_rf(metrics_dir, secure: true) - prometheus_config.multiprocess_files_dir = old_metrics_dir + allow(Gitlab::Metrics::Samplers::RubySampler).to receive(:initialize_instance).and_return(ruby_sampler_double) + allow(ruby_sampler_double).to receive(:start) end it 'configures ::Prometheus::Client' do metrics_server.start expect(prometheus_config.multiprocess_files_dir).to eq metrics_dir + expect(::Prometheus::Client.configuration.pid_provider.call).to eq 'fake_exporter' end it 'ensures that metrics directory exists in correct mode (0700)' do @@ -105,5 +117,11 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath metrics_server.start end + + it 'starts a RubySampler instance' do + expect(ruby_sampler_double).to receive(:start) + + subject.start + end end end diff --git a/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb b/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb deleted file mode 100644 index b8dc4d7c8ae..00000000000 --- a/spec/migrations/20210112143418_remove_duplicate_services2_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveDuplicateServices2 do - let_it_be(:namespaces) { table(:namespaces) } - let_it_be(:projects) { table(:projects) } - let_it_be(:services) { table(:services) } - - describe '#up' do - before do - stub_const("#{described_class}::BATCH_SIZE", 2) - - namespaces.create!(id: 1, name: 'group', path: 'group') - - projects.create!(id: 1, namespace_id: 1) # duplicate services - projects.create!(id: 2, namespace_id: 1) # normal services - projects.create!(id: 3, namespace_id: 1) # no services - projects.create!(id: 4, namespace_id: 1) # duplicate services - projects.create!(id: 5, namespace_id: 1) # duplicate services - - services.create!(id: 1, project_id: 1, type: 'JiraService') - services.create!(id: 2, project_id: 1, type: 'JiraService') - services.create!(id: 3, project_id: 2, type: 'JiraService') - services.create!(id: 4, project_id: 4, type: 'AsanaService') - services.create!(id: 5, project_id: 4, type: 'AsanaService') - services.create!(id: 6, project_id: 4, type: 'JiraService') - services.create!(id: 7, project_id: 4, type: 'JiraService') - services.create!(id: 8, project_id: 4, type: 'SlackService') - services.create!(id: 9, project_id: 4, type: 'SlackService') - services.create!(id: 10, project_id: 5, type: 'JiraService') - services.create!(id: 11, project_id: 5, type: 'JiraService') - - # Services without a project_id should be ignored - services.create!(id: 12, type: 'JiraService') - services.create!(id: 13, type: 'JiraService') - end - - it 'schedules background jobs for all projects with duplicate services' do - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, 1, 4) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, 5) - end - end - end - end -end diff --git a/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb b/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb deleted file mode 100644 index e07b5a48909..00000000000 --- a/spec/migrations/20210119122354_alter_vsa_issue_first_mentioned_in_commit_value_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AlterVsaIssueFirstMentionedInCommitValue, schema: 20210114033715 do - let(:group_stages) { table(:analytics_cycle_analytics_group_stages) } - let(:value_streams) { table(:analytics_cycle_analytics_group_value_streams) } - let(:namespaces) { table(:namespaces) } - - let(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') } - let(:value_stream) { value_streams.create!(name: 'test', group_id: namespace.id) } - - let!(:stage_1) { group_stages.create!(group_value_stream_id: value_stream.id, group_id: namespace.id, name: 'stage 1', start_event_identifier: described_class::ISSUE_FIRST_MENTIONED_IN_COMMIT_EE, end_event_identifier: 1) } - let!(:stage_2) { group_stages.create!(group_value_stream_id: value_stream.id, group_id: namespace.id, name: 'stage 2', start_event_identifier: 2, end_event_identifier: described_class::ISSUE_FIRST_MENTIONED_IN_COMMIT_EE) } - let!(:stage_3) { group_stages.create!(group_value_stream_id: value_stream.id, group_id: namespace.id, name: 'stage 3', start_event_identifier: described_class::ISSUE_FIRST_MENTIONED_IN_COMMIT_FOSS, end_event_identifier: 3) } - - describe '#up' do - it 'changes the EE specific identifier values to the FOSS version' do - migrate! - - expect(stage_1.reload.start_event_identifier).to eq(described_class::ISSUE_FIRST_MENTIONED_IN_COMMIT_FOSS) - expect(stage_2.reload.end_event_identifier).to eq(described_class::ISSUE_FIRST_MENTIONED_IN_COMMIT_FOSS) - end - - it 'does not change irrelevant records' do - expect { migrate! }.not_to change { stage_3.reload } - end - end -end diff --git a/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb b/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb deleted file mode 100644 index 97438062458..00000000000 --- a/spec/migrations/20210205174154_remove_bad_dependency_proxy_manifests_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveBadDependencyProxyManifests, schema: 20210128140157 do - let_it_be(:namespaces) { table(:namespaces) } - let_it_be(:dependency_proxy_manifests) { table(:dependency_proxy_manifests) } - let_it_be(:group) { namespaces.create!(type: 'Group', name: 'test', path: 'test') } - - let_it_be(:dependency_proxy_manifest_with_content_type) do - dependency_proxy_manifests.create!(group_id: group.id, file: 'foo', file_name: 'foo', digest: 'asdf1234', content_type: 'content-type' ) - end - - let_it_be(:dependency_proxy_manifest_without_content_type) do - dependency_proxy_manifests.create!(group_id: group.id, file: 'bar', file_name: 'bar', digest: 'fdsa6789') - end - - it 'removes the dependency_proxy_manifests with a content_type', :aggregate_failures do - expect(dependency_proxy_manifest_with_content_type).to be_present - expect(dependency_proxy_manifest_without_content_type).to be_present - - expect { migrate! }.to change { dependency_proxy_manifests.count }.from(2).to(1) - - expect(dependency_proxy_manifests.where.not(content_type: nil)).to be_empty - expect(dependency_proxy_manifest_without_content_type.reload).to be_present - end -end diff --git a/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb b/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb deleted file mode 100644 index 4a31d36e2bc..00000000000 --- a/spec/migrations/20210210093901_backfill_updated_at_after_repository_storage_move_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe BackfillUpdatedAtAfterRepositoryStorageMove, :sidekiq do - let_it_be(:projects) { table(:projects) } - let_it_be(:project_repository_storage_moves) { table(:project_repository_storage_moves) } - let_it_be(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } - - describe '#up' do - it 'schedules background jobs for all distinct projects in batches' do - stub_const("#{described_class}::BATCH_SIZE", 3) - - project_1 = projects.create!(id: 1, namespace_id: namespace.id) - project_2 = projects.create!(id: 2, namespace_id: namespace.id) - project_3 = projects.create!(id: 3, namespace_id: namespace.id) - project_4 = projects.create!(id: 4, namespace_id: namespace.id) - project_5 = projects.create!(id: 5, namespace_id: namespace.id) - project_6 = projects.create!(id: 6, namespace_id: namespace.id) - project_7 = projects.create!(id: 7, namespace_id: namespace.id) - projects.create!(id: 8, namespace_id: namespace.id) - - project_repository_storage_moves.create!(id: 1, project_id: project_1.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 2, project_id: project_1.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 3, project_id: project_2.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 4, project_id: project_3.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 5, project_id: project_3.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 6, project_id: project_4.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 7, project_id: project_4.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 8, project_id: project_5.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 9, project_id: project_6.id, source_storage_name: 'default', destination_storage_name: 'default') - project_repository_storage_moves.create!(id: 10, project_id: project_7.id, source_storage_name: 'default', destination_storage_name: 'default') - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(3) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, 1, 2, 3) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, 4, 5, 6) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, 7) - end - end - end - end -end diff --git a/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb b/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb deleted file mode 100644 index 039ce53cac4..00000000000 --- a/spec/migrations/20210218040814_add_environment_scope_to_group_variables_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe AddEnvironmentScopeToGroupVariables do - let(:migration) { described_class.new } - let(:ci_group_variables) { table(:ci_group_variables) } - let(:group) { table(:namespaces).create!(name: 'group', path: 'group') } - - def create_variable!(group, key:, environment_scope: '*') - table(:ci_group_variables).create!( - group_id: group.id, - key: key, - environment_scope: environment_scope - ) - end - - describe '#down' do - context 'group has variables with duplicate keys' do - it 'deletes all but the first record' do - migration.up - - remaining_variable = create_variable!(group, key: 'key') - create_variable!(group, key: 'key', environment_scope: 'staging') - create_variable!(group, key: 'key', environment_scope: 'production') - - migration.down - - expect(ci_group_variables.pluck(:id)).to eq [remaining_variable.id] - end - end - - context 'group does not have variables with duplicate keys' do - it 'does not delete any records' do - migration.up - - create_variable!(group, key: 'key') - create_variable!(group, key: 'staging') - create_variable!(group, key: 'production') - - expect { migration.down }.not_to change { ci_group_variables.count } - end - end - end -end diff --git a/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb b/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb deleted file mode 100644 index 1b57bf0431f..00000000000 --- a/spec/migrations/20210226141517_dedup_issue_metrics_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DedupIssueMetrics, :migration, schema: 20210205104425 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:issues) { table(:issues) } - let(:metrics) { table(:issue_metrics) } - let(:issue_params) { { title: 'title', project_id: project.id } } - - let!(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } - let!(:project) { projects.create!(namespace_id: namespace.id) } - let!(:issue_1) { issues.create!(issue_params) } - let!(:issue_2) { issues.create!(issue_params) } - let!(:issue_3) { issues.create!(issue_params) } - - let!(:duplicated_metrics_1) { metrics.create!(issue_id: issue_1.id, first_mentioned_in_commit_at: 1.day.ago, first_added_to_board_at: 5.days.ago, updated_at: 2.months.ago) } - let!(:duplicated_metrics_2) { metrics.create!(issue_id: issue_1.id, first_mentioned_in_commit_at: Time.now, first_associated_with_milestone_at: Time.now, updated_at: 1.month.ago) } - - let!(:duplicated_metrics_3) { metrics.create!(issue_id: issue_3.id, first_mentioned_in_commit_at: 1.day.ago, updated_at: 2.months.ago) } - let!(:duplicated_metrics_4) { metrics.create!(issue_id: issue_3.id, first_added_to_board_at: 1.day.ago, updated_at: 1.month.ago) } - - let!(:non_duplicated_metrics) { metrics.create!(issue_id: issue_2.id, first_added_to_board_at: 2.days.ago) } - - it 'deduplicates issue_metrics table' do - expect { migrate! }.to change { metrics.count }.from(5).to(3) - end - - it 'merges `duplicated_metrics_1` with `duplicated_metrics_2`' do - migrate! - - expect(metrics.where(id: duplicated_metrics_1.id)).not_to exist - - merged_metrics = metrics.find_by(id: duplicated_metrics_2.id) - - expect(merged_metrics).to be_present - expect(merged_metrics.first_mentioned_in_commit_at).to be_like_time(duplicated_metrics_2.first_mentioned_in_commit_at) - expect(merged_metrics.first_added_to_board_at).to be_like_time(duplicated_metrics_1.first_added_to_board_at) - end - - it 'merges `duplicated_metrics_3` with `duplicated_metrics_4`' do - migrate! - - expect(metrics.where(id: duplicated_metrics_3.id)).not_to exist - - merged_metrics = metrics.find_by(id: duplicated_metrics_4.id) - - expect(merged_metrics).to be_present - expect(merged_metrics.first_mentioned_in_commit_at).to be_like_time(duplicated_metrics_3.first_mentioned_in_commit_at) - expect(merged_metrics.first_added_to_board_at).to be_like_time(duplicated_metrics_4.first_added_to_board_at) - end - - it 'does not change non duplicated records' do - expect { migrate! }.not_to change { non_duplicated_metrics.reload.attributes } - end - - it 'does nothing when there are no metrics' do - metrics.delete_all - - migrate! - - expect(metrics.count).to eq(0) - end -end diff --git a/spec/migrations/20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb deleted file mode 100644 index 5a2531bb63f..00000000000 --- a/spec/migrations/20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' -require Rails.root.join('db', 'post_migrate', '20210918202855_reschedule_pending_jobs_for_recalculate_vulnerabilities_occurrences_uuid.rb') - -RSpec.describe ReschedulePendingJobsForRecalculateVulnerabilitiesOccurrencesUuid, :migration do - let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } - - context 'when RecalculateVulnerabilitiesOccurrencesUuid jobs are pending' do - before do - background_migration_jobs.create!( - class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', - arguments: [1, 2, 3], - status: Gitlab::Database::BackgroundMigrationJob.statuses['pending'] - ) - background_migration_jobs.create!( - class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', - arguments: [4, 5, 6], - status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] - ) - end - - it 'queues pending jobs' do - migrate! - - expect(BackgroundMigrationWorker.jobs.length).to eq(1) - expect(BackgroundMigrationWorker.jobs[0]['args']).to eq(['RecalculateVulnerabilitiesOccurrencesUuid', [1, 2, 3]]) - expect(BackgroundMigrationWorker.jobs[0]['at']).to be_nil - end - end -end diff --git a/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb b/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb new file mode 100644 index 00000000000..491aad1b30b --- /dev/null +++ b/spec/migrations/20211207125331_remove_jobs_for_recalculate_vulnerabilities_occurrences_uuid_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +require 'spec_helper' +require_migration! + +def create_background_migration_jobs(ids, status, created_at) + proper_status = case status + when :pending + Gitlab::Database::BackgroundMigrationJob.statuses['pending'] + when :succeeded + Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] + else + raise ArgumentError + end + + background_migration_jobs.create!( + class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', + arguments: Array(ids), + status: proper_status, + created_at: created_at + ) +end + +RSpec.describe RemoveJobsForRecalculateVulnerabilitiesOccurrencesUuid, :migration do + let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } + + context 'when RecalculateVulnerabilitiesOccurrencesUuid jobs are present' do + before do + create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 5, 5, 0, 2)) + create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 5, 5, 0, 4)) + + create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 8, 18, 0, 0)) + create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 8, 18, 0, 2)) + create_background_migration_jobs([7, 8, 9], :pending, DateTime.new(2021, 8, 18, 0, 4)) + end + + it 'removes all jobs' do + expect(background_migration_jobs.count).to eq(5) + + migrate! + + expect(background_migration_jobs.count).to eq(0) + end + end +end diff --git a/spec/migrations/schedule_recalculate_uuid_on_vulnerabilities_occurrences3_spec.rb b/spec/migrations/20211207135331_schedule_recalculate_uuid_on_vulnerabilities_occurrences4_spec.rb index 77f298b5ecb..71ffcafaae1 100644 --- a/spec/migrations/schedule_recalculate_uuid_on_vulnerabilities_occurrences3_spec.rb +++ b/spec/migrations/20211207135331_schedule_recalculate_uuid_on_vulnerabilities_occurrences4_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require_migration! -RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do +RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences4 do let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } @@ -13,6 +13,7 @@ RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } let(:vulnerabilities) { table(:vulnerabilities) } let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } + let(:vulnerability_finding_signatures) { table(:vulnerability_finding_signatures) } let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } let(:vulnerability_identifier) do vulnerability_identifiers.create!( @@ -32,6 +33,17 @@ RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do name: 'Identifier for UUIDv4') end + let!(:uuidv4_finding) do + create_finding!( + vulnerability_id: vulnerability_for_uuidv4.id, + project_id: project.id, + scanner_id: different_scanner.id, + primary_identifier_id: different_vulnerability_identifier.id, + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('fa18f432f1d56675f4098d318739c3cd5b14eb3e'), + uuid: 'b3cc2518-5446-4dea-871c-89d5e999c1ac' + ) + end + let(:vulnerability_for_uuidv4) do create_vulnerability!( project_id: project.id, @@ -39,6 +51,17 @@ RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do ) end + let!(:uuidv5_finding) do + create_finding!( + vulnerability_id: vulnerability_for_uuidv5.id, + project_id: project.id, + scanner_id: scanner.id, + primary_identifier_id: vulnerability_identifier.id, + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('838574be0210968bf6b9f569df9c2576242cbf0a'), + uuid: '77211ed6-7dff-5f6b-8c9a-da89ad0a9b60' + ) + end + let(:vulnerability_for_uuidv5) do create_vulnerability!( project_id: project.id, @@ -46,25 +69,22 @@ RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do ) end - let!(:finding1) do - create_finding!( - vulnerability_id: vulnerability_for_uuidv4.id, + let(:vulnerability_for_finding_with_signature) do + create_vulnerability!( project_id: project.id, - scanner_id: different_scanner.id, - primary_identifier_id: different_vulnerability_identifier.id, - location_fingerprint: 'fa18f432f1d56675f4098d318739c3cd5b14eb3e', - uuid: 'b3cc2518-5446-4dea-871c-89d5e999c1ac' + author_id: user.id ) end - let!(:finding2) do + let!(:finding_with_signature) do create_finding!( - vulnerability_id: vulnerability_for_uuidv5.id, + vulnerability_id: vulnerability_for_finding_with_signature.id, project_id: project.id, scanner_id: scanner.id, primary_identifier_id: vulnerability_identifier.id, - location_fingerprint: '838574be0210968bf6b9f569df9c2576242cbf0a', - uuid: '77211ed6-7dff-5f6b-8c9a-da89ad0a9b60' + report_type: 0, # "sast" + location_fingerprint: Gitlab::Database::ShaAttribute.serialize('123609eafffffa2207a9ca2425ba4337h34fga1b'), + uuid: '252aa474-d689-5d2b-ab42-7bbb5a100c02' ) end @@ -79,9 +99,10 @@ RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do it 'schedules background migrations', :aggregate_failures do migrate! - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, finding1.id, finding1.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, finding2.id, finding2.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(3) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, uuidv4_finding.id, uuidv4_finding.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, uuidv5_finding.id, uuidv5_finding.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(6.minutes, finding_with_signature.id, finding_with_signature.id) end private @@ -98,14 +119,14 @@ RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences3 do end def create_finding!( - vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, location_fingerprint:, uuid:) + vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, location_fingerprint:, uuid:, report_type: 0) vulnerabilities_findings.create!( vulnerability_id: vulnerability_id, project_id: project_id, name: 'test', severity: 7, confidence: 7, - report_type: 0, + report_type: report_type, project_fingerprint: '123qweasdzxc', scanner_id: scanner_id, primary_identifier_id: primary_identifier_id, diff --git a/spec/migrations/20211210140629_encrypt_static_object_token_spec.rb b/spec/migrations/20211210140629_encrypt_static_object_token_spec.rb new file mode 100644 index 00000000000..289cf9a93ed --- /dev/null +++ b/spec/migrations/20211210140629_encrypt_static_object_token_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'spec_helper' + +require_migration! + +RSpec.describe EncryptStaticObjectToken, :migration do + let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } + let_it_be(:users) { table(:users) } + + let!(:user_without_tokens) { create_user!(name: 'notoken') } + let!(:user_with_plaintext_token_1) { create_user!(name: 'plaintext_1', token: 'token') } + let!(:user_with_plaintext_token_2) { create_user!(name: 'plaintext_2', token: 'TOKEN') } + let!(:user_with_encrypted_token) { create_user!(name: 'encrypted', encrypted_token: 'encrypted') } + let!(:user_with_both_tokens) { create_user!(name: 'both', token: 'token2', encrypted_token: 'encrypted2') } + + before do + stub_const("#{described_class}::BATCH_SIZE", 1) + end + + around do |example| + freeze_time { Sidekiq::Testing.fake! { example.run } } + end + + it 'schedules background migrations' do + migrate! + + expect(background_migration_jobs.count).to eq(2) + expect(background_migration_jobs.first.arguments).to match_array([user_with_plaintext_token_1.id, user_with_plaintext_token_1.id]) + expect(background_migration_jobs.second.arguments).to match_array([user_with_plaintext_token_2.id, user_with_plaintext_token_2.id]) + + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, user_with_plaintext_token_1.id, user_with_plaintext_token_1.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, user_with_plaintext_token_2.id, user_with_plaintext_token_2.id) + end + + private + + def create_user!(name:, token: nil, encrypted_token: nil) + email = "#{name}@example.com" + + table(:users).create!( + name: name, + email: email, + username: name, + projects_limit: 0, + static_object_token: token, + static_object_token_encrypted: encrypted_token + ) + end +end diff --git a/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb b/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb new file mode 100644 index 00000000000..a17fee6bab2 --- /dev/null +++ b/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe BackfillIncidentIssueEscalationStatuses do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:issues) { table(:issues) } + let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') } + let(:project) { projects.create!(namespace_id: namespace.id) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 1) + end + + it 'schedules jobs for incident issues' do + issue_1 = issues.create!(project_id: project.id) # non-incident issue + incident_1 = issues.create!(project_id: project.id, issue_type: 1) + incident_2 = issues.create!(project_id: project.id, issue_type: 1) + + Sidekiq::Testing.fake! do + freeze_time do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration( + 2.minutes, issue_1.id, issue_1.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration( + 4.minutes, incident_1.id, incident_1.id) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration( + 6.minutes, incident_2.id, incident_2.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(3) + end + end + end +end diff --git a/spec/migrations/20211217174331_mark_recalculate_finding_signatures_as_completed_spec.rb b/spec/migrations/20211217174331_mark_recalculate_finding_signatures_as_completed_spec.rb new file mode 100644 index 00000000000..c5058f30d82 --- /dev/null +++ b/spec/migrations/20211217174331_mark_recalculate_finding_signatures_as_completed_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +require 'spec_helper' +require_migration! + +def create_background_migration_jobs(ids, status, created_at) + proper_status = case status + when :pending + Gitlab::Database::BackgroundMigrationJob.statuses['pending'] + when :succeeded + Gitlab::Database::BackgroundMigrationJob.statuses['succeeded'] + else + raise ArgumentError + end + + background_migration_jobs.create!( + class_name: 'RecalculateVulnerabilitiesOccurrencesUuid', + arguments: Array(ids), + status: proper_status, + created_at: created_at + ) +end + +RSpec.describe MarkRecalculateFindingSignaturesAsCompleted, :migration do + let_it_be(:background_migration_jobs) { table(:background_migration_jobs) } + + context 'when RecalculateVulnerabilitiesOccurrencesUuid jobs are present' do + before do + create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 5, 5, 0, 2)) + create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 5, 5, 0, 4)) + + create_background_migration_jobs([1, 2, 3], :succeeded, DateTime.new(2021, 8, 18, 0, 0)) + create_background_migration_jobs([4, 5, 6], :pending, DateTime.new(2021, 8, 18, 0, 2)) + create_background_migration_jobs([7, 8, 9], :pending, DateTime.new(2021, 8, 18, 0, 4)) + end + + describe 'gitlab.com' do + before do + allow(::Gitlab).to receive(:com?).and_return(true) + end + + it 'marks all jobs as succeeded' do + expect(background_migration_jobs.where(status: 1).count).to eq(2) + + migrate! + + expect(background_migration_jobs.where(status: 1).count).to eq(5) + end + end + + describe 'self managed' do + before do + allow(::Gitlab).to receive(:com?).and_return(false) + end + + it 'does not change job status' do + expect(background_migration_jobs.where(status: 1).count).to eq(2) + + migrate! + + expect(background_migration_jobs.where(status: 1).count).to eq(2) + end + end + end +end diff --git a/spec/migrations/add_has_external_issue_tracker_trigger_spec.rb b/spec/migrations/add_has_external_issue_tracker_trigger_spec.rb deleted file mode 100644 index 72983c7cfbf..00000000000 --- a/spec/migrations/add_has_external_issue_tracker_trigger_spec.rb +++ /dev/null @@ -1,164 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe AddHasExternalIssueTrackerTrigger do - let(:migration) { described_class.new } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:services) { table(:services) } - - before do - @namespace = namespaces.create!(name: 'foo', path: 'foo') - @project = projects.create!(namespace_id: @namespace.id) - end - - describe '#up' do - before do - migrate! - end - - describe 'INSERT trigger' do - it 'sets `has_external_issue_tracker` to true when active `issue_tracker` is inserted' do - expect do - services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - end.to change { @project.reload.has_external_issue_tracker }.to(true) - end - - it 'does not set `has_external_issue_tracker` to true when service is for a different project' do - different_project = projects.create!(namespace_id: @namespace.id) - - expect do - services.create!(category: 'issue_tracker', active: true, project_id: different_project.id) - end.not_to change { @project.reload.has_external_issue_tracker } - end - - it 'does not set `has_external_issue_tracker` to true when inactive `issue_tracker` is inserted' do - expect do - services.create!(category: 'issue_tracker', active: false, project_id: @project.id) - end.not_to change { @project.reload.has_external_issue_tracker } - end - - it 'does not set `has_external_issue_tracker` to true when a non-`issue tracker` active service is inserted' do - expect do - services.create!(category: 'my_type', active: true, project_id: @project.id) - end.not_to change { @project.reload.has_external_issue_tracker } - end - end - - describe 'UPDATE trigger' do - it 'sets `has_external_issue_tracker` to true when `issue_tracker` is made active' do - service = services.create!(category: 'issue_tracker', active: false, project_id: @project.id) - - expect do - service.update!(active: true) - end.to change { @project.reload.has_external_issue_tracker }.to(true) - end - - it 'sets `has_external_issue_tracker` to false when `issue_tracker` is made inactive' do - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - - expect do - service.update!(active: false) - end.to change { @project.reload.has_external_issue_tracker }.to(false) - end - - it 'sets `has_external_issue_tracker` to false when `issue_tracker` is made inactive, and an inactive `issue_tracker` exists' do - services.create!(category: 'issue_tracker', active: false, project_id: @project.id) - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - - expect do - service.update!(active: false) - end.to change { @project.reload.has_external_issue_tracker }.to(false) - end - - it 'does not change `has_external_issue_tracker` when `issue_tracker` is made inactive, if an active `issue_tracker` exists' do - services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - - expect do - service.update!(active: false) - end.not_to change { @project.reload.has_external_issue_tracker } - end - - it 'does not change `has_external_issue_tracker` when service is for a different project' do - different_project = projects.create!(namespace_id: @namespace.id) - service = services.create!(category: 'issue_tracker', active: false, project_id: different_project.id) - - expect do - service.update!(active: true) - end.not_to change { @project.reload.has_external_issue_tracker } - end - end - - describe 'DELETE trigger' do - it 'sets `has_external_issue_tracker` to false when `issue_tracker` is deleted' do - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - - expect do - service.delete - end.to change { @project.reload.has_external_issue_tracker }.to(false) - end - - it 'sets `has_external_issue_tracker` to false when `issue_tracker` is deleted, if an inactive `issue_tracker` still exists' do - services.create!(category: 'issue_tracker', active: false, project_id: @project.id) - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - - expect do - service.delete - end.to change { @project.reload.has_external_issue_tracker }.to(false) - end - - it 'does not change `has_external_issue_tracker` when `issue_tracker` is deleted, if an active `issue_tracker` still exists' do - services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - - expect do - service.delete - end.not_to change { @project.reload.has_external_issue_tracker } - end - - it 'does not change `has_external_issue_tracker` when service is for a different project' do - different_project = projects.create!(namespace_id: @namespace.id) - service = services.create!(category: 'issue_tracker', active: true, project_id: different_project.id) - - expect do - service.delete - end.not_to change { @project.reload.has_external_issue_tracker } - end - end - end - - describe '#down' do - before do - migration.up - migration.down - end - - it 'drops the INSERT trigger' do - expect do - services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - end.not_to change { @project.reload.has_external_issue_tracker } - end - - it 'drops the UPDATE trigger' do - service = services.create!(category: 'issue_tracker', active: false, project_id: @project.id) - @project.update!(has_external_issue_tracker: false) - - expect do - service.update!(active: true) - end.not_to change { @project.reload.has_external_issue_tracker } - end - - it 'drops the DELETE trigger' do - service = services.create!(category: 'issue_tracker', active: true, project_id: @project.id) - @project.update!(has_external_issue_tracker: true) - - expect do - service.delete - end.not_to change { @project.reload.has_external_issue_tracker } - end - end -end diff --git a/spec/migrations/add_has_external_wiki_trigger_spec.rb b/spec/migrations/add_has_external_wiki_trigger_spec.rb deleted file mode 100644 index 10c6888c87e..00000000000 --- a/spec/migrations/add_has_external_wiki_trigger_spec.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe AddHasExternalWikiTrigger do - let(:migration) { described_class.new } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:services) { table(:services) } - - before do - @namespace = namespaces.create!(name: 'foo', path: 'foo') - @project = projects.create!(namespace_id: @namespace.id) - end - - describe '#up' do - before do - migrate! - end - - describe 'INSERT trigger' do - it 'sets `has_external_wiki` to true when active `ExternalWikiService` is inserted' do - expect do - services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) - end.to change { @project.reload.has_external_wiki }.to(true) - end - - it 'does not set `has_external_wiki` to true when service is for a different project' do - different_project = projects.create!(namespace_id: @namespace.id) - - expect do - services.create!(type: 'ExternalWikiService', active: true, project_id: different_project.id) - end.not_to change { @project.reload.has_external_wiki } - end - - it 'does not set `has_external_wiki` to true when inactive `ExternalWikiService` is inserted' do - expect do - services.create!(type: 'ExternalWikiService', active: false, project_id: @project.id) - end.not_to change { @project.reload.has_external_wiki } - end - - it 'does not set `has_external_wiki` to true when active other service is inserted' do - expect do - services.create!(type: 'MyService', active: true, project_id: @project.id) - end.not_to change { @project.reload.has_external_wiki } - end - end - - describe 'UPDATE trigger' do - it 'sets `has_external_wiki` to true when `ExternalWikiService` is made active' do - service = services.create!(type: 'ExternalWikiService', active: false, project_id: @project.id) - - expect do - service.update!(active: true) - end.to change { @project.reload.has_external_wiki }.to(true) - end - - it 'sets `has_external_wiki` to false when `ExternalWikiService` is made inactive' do - service = services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) - - expect do - service.update!(active: false) - end.to change { @project.reload.has_external_wiki }.to(false) - end - - it 'does not change `has_external_wiki` when service is for a different project' do - different_project = projects.create!(namespace_id: @namespace.id) - service = services.create!(type: 'ExternalWikiService', active: false, project_id: different_project.id) - - expect do - service.update!(active: true) - end.not_to change { @project.reload.has_external_wiki } - end - end - - describe 'DELETE trigger' do - it 'sets `has_external_wiki` to false when `ExternalWikiService` is deleted' do - service = services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) - - expect do - service.delete - end.to change { @project.reload.has_external_wiki }.to(false) - end - - it 'does not change `has_external_wiki` when service is for a different project' do - different_project = projects.create!(namespace_id: @namespace.id) - service = services.create!(type: 'ExternalWikiService', active: true, project_id: different_project.id) - - expect do - service.delete - end.not_to change { @project.reload.has_external_wiki } - end - end - end - - describe '#down' do - before do - migration.up - migration.down - end - - it 'drops the INSERT trigger' do - expect do - services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) - end.not_to change { @project.reload.has_external_wiki } - end - - it 'drops the UPDATE trigger' do - service = services.create!(type: 'ExternalWikiService', active: false, project_id: @project.id) - @project.update!(has_external_wiki: false) - - expect do - service.update!(active: true) - end.not_to change { @project.reload.has_external_wiki } - end - - it 'drops the DELETE trigger' do - service = services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) - @project.update!(has_external_wiki: true) - - expect do - service.delete - end.not_to change { @project.reload.has_external_wiki } - end - end -end diff --git a/spec/migrations/add_new_post_eoa_plans_spec.rb b/spec/migrations/add_new_post_eoa_plans_spec.rb deleted file mode 100644 index 02360d5a12d..00000000000 --- a/spec/migrations/add_new_post_eoa_plans_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe AddNewPostEoaPlans do - let(:plans) { table(:plans) } - - subject(:migration) { described_class.new } - - describe '#up' do - it 'creates the two new records' do - expect { migration.up }.to change { plans.count }.by(2) - - new_plans = plans.last(2) - expect(new_plans.map(&:name)).to contain_exactly('premium', 'ultimate') - end - end - - describe '#down' do - it 'removes these two new records' do - plans.create!(name: 'premium', title: 'Premium (Formerly Silver)') - plans.create!(name: 'ultimate', title: 'Ultimate (Formerly Gold)') - - expect { migration.down }.to change { plans.count }.by(-2) - - expect(plans.find_by(name: 'premium')).to be_nil - expect(plans.find_by(name: 'ultimate')).to be_nil - end - end -end diff --git a/spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb b/spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb new file mode 100644 index 00000000000..abff7c6aba1 --- /dev/null +++ b/spec/migrations/cleanup_after_add_primary_email_to_emails_if_user_confirmed_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CleanupAfterAddPrimaryEmailToEmailsIfUserConfirmed, :sidekiq do + let(:migration) { described_class.new } + let(:users) { table(:users) } + let(:emails) { table(:emails) } + + let!(:user_1) { users.create!(name: 'confirmed-user-1', email: 'confirmed-1@example.com', confirmed_at: 3.days.ago, projects_limit: 100) } + let!(:user_2) { users.create!(name: 'confirmed-user-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_3) { users.create!(name: 'confirmed-user-3', email: 'confirmed-3@example.com', confirmed_at: 1.day.ago, projects_limit: 100) } + let!(:user_4) { users.create!(name: 'unconfirmed-user', email: 'unconfirmed@example.com', confirmed_at: nil, projects_limit: 100) } + + let!(:email_1) { emails.create!(email: 'confirmed-1@example.com', user_id: user_1.id, confirmed_at: 1.day.ago) } + let!(:email_2) { emails.create!(email: 'other_2@example.com', user_id: user_2.id, confirmed_at: 1.day.ago) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 2) + end + + it 'consume any pending background migration job' do + expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| + expect(coordinator).to receive(:steal).with('AddPrimaryEmailToEmailsIfUserConfirmed').twice + end + + migration.up + end + + it 'adds the primary email to emails for leftover confirmed users that do not have their primary email in the emails table', :aggregate_failures do + original_email_1_confirmed_at = email_1.reload.confirmed_at + + expect { migration.up }.to change { emails.count }.by(2) + + expect(emails.find_by(user_id: user_2.id, email: 'confirmed-2@example.com').confirmed_at).to eq(user_2.reload.confirmed_at) + expect(emails.find_by(user_id: user_3.id, email: 'confirmed-3@example.com').confirmed_at).to eq(user_3.reload.confirmed_at) + expect(email_1.reload.confirmed_at).to eq(original_email_1_confirmed_at) + + expect(emails.exists?(user_id: user_4.id)).to be(false) + end + + it 'continues in case of errors with one email' do + allow(Email).to receive(:create) { raise 'boom!' } + + expect { migration.up }.not_to raise_error + end +end diff --git a/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb b/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb deleted file mode 100644 index 8aedd1f9607..00000000000 --- a/spec/migrations/cleanup_projects_with_bad_has_external_issue_tracker_data_spec.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe CleanupProjectsWithBadHasExternalIssueTrackerData, :migration do - let(:namespace) { table(:namespaces).create!(name: 'foo', path: 'bar') } - let(:projects) { table(:projects) } - let(:services) { table(:services) } - - def create_projects!(num) - Array.new(num) do - projects.create!(namespace_id: namespace.id) - end - end - - def create_active_external_issue_tracker_integrations!(*projects) - projects.each do |project| - services.create!(category: 'issue_tracker', project_id: project.id, active: true) - end - end - - def create_disabled_external_issue_tracker_integrations!(*projects) - projects.each do |project| - services.create!(category: 'issue_tracker', project_id: project.id, active: false) - end - end - - def create_active_other_integrations!(*projects) - projects.each do |project| - services.create!(category: 'not_an_issue_tracker', project_id: project.id, active: true) - end - end - - it 'sets `projects.has_external_issue_tracker` correctly' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) - - project_with_an_external_issue_tracker_1, - project_with_an_external_issue_tracker_2, - project_with_only_a_disabled_external_issue_tracker_1, - project_with_only_a_disabled_external_issue_tracker_2, - project_without_any_external_issue_trackers_1, - project_without_any_external_issue_trackers_2 = create_projects!(6) - - create_active_external_issue_tracker_integrations!( - project_with_an_external_issue_tracker_1, - project_with_an_external_issue_tracker_2 - ) - - create_disabled_external_issue_tracker_integrations!( - project_with_an_external_issue_tracker_1, - project_with_an_external_issue_tracker_2, - project_with_only_a_disabled_external_issue_tracker_1, - project_with_only_a_disabled_external_issue_tracker_2 - ) - - create_active_other_integrations!( - project_with_an_external_issue_tracker_1, - project_with_an_external_issue_tracker_2, - project_without_any_external_issue_trackers_1, - project_without_any_external_issue_trackers_2 - ) - - # PG triggers on the services table added in - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51852 will have set - # the `has_external_issue_tracker` columns to correct data when the services - # records were created above. - # - # We set the `has_external_issue_tracker` columns for projects to incorrect - # data manually below to emulate projects in a state before the PG - # triggers were added. - project_with_an_external_issue_tracker_2.update!(has_external_issue_tracker: false) - project_with_only_a_disabled_external_issue_tracker_2.update!(has_external_issue_tracker: true) - project_without_any_external_issue_trackers_2.update!(has_external_issue_tracker: true) - - migrate! - - expected_true = [ - project_with_an_external_issue_tracker_1, - project_with_an_external_issue_tracker_2 - ].each(&:reload).map(&:has_external_issue_tracker) - - expected_not_true = [ - project_without_any_external_issue_trackers_1, - project_without_any_external_issue_trackers_2, - project_with_only_a_disabled_external_issue_tracker_1, - project_with_only_a_disabled_external_issue_tracker_2 - ].each(&:reload).map(&:has_external_issue_tracker) - - expect(expected_true).to all(eq(true)) - expect(expected_not_true).to all(be_falsey) - end -end diff --git a/spec/migrations/cleanup_projects_with_bad_has_external_wiki_data_spec.rb b/spec/migrations/cleanup_projects_with_bad_has_external_wiki_data_spec.rb deleted file mode 100644 index ee1f718849f..00000000000 --- a/spec/migrations/cleanup_projects_with_bad_has_external_wiki_data_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe CleanupProjectsWithBadHasExternalWikiData, :migration do - let(:namespace) { table(:namespaces).create!(name: 'foo', path: 'bar') } - let(:projects) { table(:projects) } - let(:services) { table(:services) } - - def create_projects!(num) - Array.new(num) do - projects.create!(namespace_id: namespace.id) - end - end - - def create_active_external_wiki_integrations!(*projects) - projects.each do |project| - services.create!(type: 'ExternalWikiService', project_id: project.id, active: true) - end - end - - def create_disabled_external_wiki_integrations!(*projects) - projects.each do |project| - services.create!(type: 'ExternalWikiService', project_id: project.id, active: false) - end - end - - def create_active_other_integrations!(*projects) - projects.each do |project| - services.create!(type: 'NotAnExternalWikiService', project_id: project.id, active: true) - end - end - - it 'sets `projects.has_external_wiki` correctly' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) - - project_with_external_wiki_1, - project_with_external_wiki_2, - project_with_disabled_external_wiki_1, - project_with_disabled_external_wiki_2, - project_without_external_wiki_1, - project_without_external_wiki_2 = create_projects!(6) - - create_active_external_wiki_integrations!( - project_with_external_wiki_1, - project_with_external_wiki_2 - ) - - create_disabled_external_wiki_integrations!( - project_with_disabled_external_wiki_1, - project_with_disabled_external_wiki_2 - ) - - create_active_other_integrations!( - project_without_external_wiki_1, - project_without_external_wiki_2 - ) - - # PG triggers on the services table added in a previous migration - # will have set the `has_external_wiki` columns to correct data when - # the services records were created above. - # - # We set the `has_external_wiki` columns for projects to incorrect - # data manually below to emulate projects in a state before the PG - # triggers were added. - project_with_external_wiki_2.update!(has_external_wiki: false) - project_with_disabled_external_wiki_2.update!(has_external_wiki: true) - project_without_external_wiki_2.update!(has_external_wiki: true) - - migrate! - - expected_true = [ - project_with_external_wiki_1, - project_with_external_wiki_2 - ].each(&:reload).map(&:has_external_wiki) - - expected_not_true = [ - project_without_external_wiki_1, - project_without_external_wiki_2, - project_with_disabled_external_wiki_1, - project_with_disabled_external_wiki_2 - ].each(&:reload).map(&:has_external_wiki) - - expect(expected_true).to all(eq(true)) - expect(expected_not_true).to all(be_falsey) - end -end diff --git a/spec/migrations/drop_alerts_service_data_spec.rb b/spec/migrations/drop_alerts_service_data_spec.rb deleted file mode 100644 index 06382132952..00000000000 --- a/spec/migrations/drop_alerts_service_data_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe DropAlertsServiceData do - let_it_be(:alerts_service_data) { table(:alerts_service_data) } - - it 'correctly migrates up and down' do - reversible_migration do |migration| - migration.before -> { - expect(alerts_service_data.create!(service_id: 1)).to be_a alerts_service_data - } - - migration.after -> { - expect { alerts_service_data.create!(service_id: 1) } - .to raise_error(ActiveRecord::StatementInvalid, /UndefinedTable/) - } - end - end -end diff --git a/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb b/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb deleted file mode 100644 index 0f45cc842ef..00000000000 --- a/spec/migrations/migrate_delayed_project_removal_from_namespaces_to_namespace_settings_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe MigrateDelayedProjectRemovalFromNamespacesToNamespaceSettings, :migration do - let(:namespaces) { table(:namespaces) } - let(:namespace_settings) { table(:namespace_settings) } - - let!(:namespace_wo_settings) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) } - let!(:namespace_wo_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) } - let!(:namespace_w_settings_delay_true) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) } - let!(:namespace_w_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) } - - let!(:namespace_settings_delay_true) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_true.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) } - let!(:namespace_settings_delay_false) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_false.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) } - - it 'migrates delayed_project_removal to namespace_settings' do - disable_migrations_output { migrate! } - - expect(namespace_settings.count).to eq(3) - - expect(namespace_settings.find_by(namespace_id: namespace_wo_settings.id).delayed_project_removal).to eq(true) - expect(namespace_settings.find_by(namespace_id: namespace_wo_settings_delay_false.id)).to be_nil - - expect(namespace_settings_delay_true.reload.delayed_project_removal).to eq(true) - expect(namespace_settings_delay_false.reload.delayed_project_removal).to eq(false) - end -end diff --git a/spec/migrations/remove_alerts_service_records_again_spec.rb b/spec/migrations/remove_alerts_service_records_again_spec.rb deleted file mode 100644 index 94d3e957b6a..00000000000 --- a/spec/migrations/remove_alerts_service_records_again_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveAlertsServiceRecordsAgain do - let(:services) { table(:services) } - - before do - 5.times { services.create!(type: 'AlertsService') } - services.create!(type: 'SomeOtherType') - end - - it 'removes services records of type AlertsService and corresponding data', :aggregate_failures do - expect(services.count).to eq(6) - - migrate! - - expect(services.count).to eq(1) - expect(services.first.type).to eq('SomeOtherType') - expect(services.where(type: 'AlertsService')).to be_empty - end -end diff --git a/spec/migrations/remove_alerts_service_records_spec.rb b/spec/migrations/remove_alerts_service_records_spec.rb deleted file mode 100644 index 83f440f8e17..00000000000 --- a/spec/migrations/remove_alerts_service_records_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe RemoveAlertsServiceRecords do - let(:services) { table(:services) } - let(:alerts_service_data) { table(:alerts_service_data) } - - before do - 5.times do - service = services.create!(type: 'AlertsService') - alerts_service_data.create!(service_id: service.id) - end - - services.create!(type: 'SomeOtherType') - end - - it 'removes services records of type AlertsService and corresponding data', :aggregate_failures do - expect(services.count).to eq(6) - expect(alerts_service_data.count).to eq(5) - - migrate! - - expect(services.count).to eq(1) - expect(services.first.type).to eq('SomeOtherType') - expect(services.where(type: 'AlertsService')).to be_empty - expect(alerts_service_data.all).to be_empty - end -end diff --git a/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb b/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb deleted file mode 100644 index c06ce3d5bea..00000000000 --- a/spec/migrations/reschedule_artifact_expiry_backfill_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_migration! - -RSpec.describe RescheduleArtifactExpiryBackfill, :migration do - let(:migration_class) { Gitlab::BackgroundMigration::BackfillArtifactExpiryDate } - let(:migration_name) { migration_class.to_s.demodulize } - - before do - table(:namespaces).create!(id: 123, name: 'test_namespace', path: 'test_namespace') - table(:projects).create!(id: 123, name: 'sample_project', path: 'sample_project', namespace_id: 123) - end - - it 'correctly schedules background migrations' do - first_artifact = create_artifact(job_id: 0, expire_at: nil, created_at: Date.new(2020, 06, 21)) - second_artifact = create_artifact(job_id: 1, expire_at: nil, created_at: Date.new(2020, 06, 21)) - create_artifact(job_id: 2, expire_at: Date.yesterday, created_at: Date.new(2020, 06, 21)) - create_artifact(job_id: 3, expire_at: nil, created_at: Date.new(2020, 06, 23)) - - Sidekiq::Testing.fake! do - freeze_time do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(1) - expect(migration_name).to be_scheduled_migration_with_multiple_args(first_artifact.id, second_artifact.id) - end - end - end - - private - - def create_artifact(params) - table(:ci_builds).create!(id: params[:job_id], project_id: 123) - table(:ci_job_artifacts).create!(project_id: 123, file_type: 1, **params) - end -end diff --git a/spec/migrations/schedule_migrate_pages_to_zip_storage_spec.rb b/spec/migrations/schedule_migrate_pages_to_zip_storage_spec.rb index 29e4cf05c2b..52bbd5b4f6e 100644 --- a/spec/migrations/schedule_migrate_pages_to_zip_storage_spec.rb +++ b/spec/migrations/schedule_migrate_pages_to_zip_storage_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require_migration! -RSpec.describe ScheduleMigratePagesToZipStorage, :sidekiq_might_not_need_inline, schema: 20201231133921 do +RSpec.describe ScheduleMigratePagesToZipStorage, :sidekiq_might_not_need_inline, schema: 20210301200959 do let(:migration_class) { described_class::MIGRATION } let(:migration_name) { migration_class.to_s.demodulize } diff --git a/spec/migrations/schedule_populate_finding_uuid_for_vulnerability_feedback_spec.rb b/spec/migrations/schedule_populate_finding_uuid_for_vulnerability_feedback_spec.rb deleted file mode 100644 index d8bdefd5546..00000000000 --- a/spec/migrations/schedule_populate_finding_uuid_for_vulnerability_feedback_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe SchedulePopulateFindingUuidForVulnerabilityFeedback do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:users) { table(:users) } - let(:vulnerability_feedback) { table(:vulnerability_feedback) } - - let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } - let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') } - let(:user) { users.create!(username: 'john.doe', projects_limit: 1) } - - let(:common_feedback_params) { { feedback_type: 0, category: 0, project_id: project.id, author_id: user.id } } - let!(:feedback_1) { vulnerability_feedback.create!(**common_feedback_params, project_fingerprint: 'foo') } - let!(:feedback_2) { vulnerability_feedback.create!(**common_feedback_params, project_fingerprint: 'bar') } - let!(:feedback_3) { vulnerability_feedback.create!(**common_feedback_params, project_fingerprint: 'zoo', finding_uuid: SecureRandom.uuid) } - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - before do - stub_const("#{described_class.name}::BATCH_SIZE", 1) - end - - it 'schedules the background jobs', :aggregate_failures do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to be(3) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, feedback_1.id, feedback_1.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, feedback_2.id, feedback_2.id) - expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, feedback_3.id, feedback_3.id) - end -end diff --git a/spec/migrations/schedule_recalculate_uuid_on_vulnerabilities_occurrences2_spec.rb b/spec/migrations/schedule_recalculate_uuid_on_vulnerabilities_occurrences2_spec.rb deleted file mode 100644 index e7d1813e428..00000000000 --- a/spec/migrations/schedule_recalculate_uuid_on_vulnerabilities_occurrences2_spec.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_migration! - -RSpec.describe ScheduleRecalculateUuidOnVulnerabilitiesOccurrences2 do - let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } - let(:users) { table(:users) } - let(:user) { create_user! } - let(:project) { table(:projects).create!(id: 123, namespace_id: namespace.id) } - let(:scanners) { table(:vulnerability_scanners) } - let(:scanner) { scanners.create!(project_id: project.id, external_id: 'test 1', name: 'test scanner 1') } - let(:different_scanner) { scanners.create!(project_id: project.id, external_id: 'test 2', name: 'test scanner 2') } - let(:vulnerabilities) { table(:vulnerabilities) } - let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } - let(:vulnerability_identifiers) { table(:vulnerability_identifiers) } - let(:vulnerability_identifier) do - vulnerability_identifiers.create!( - project_id: project.id, - external_type: 'uuid-v5', - external_id: 'uuid-v5', - fingerprint: '7e394d1b1eb461a7406d7b1e08f057a1cf11287a', - name: 'Identifier for UUIDv5') - end - - let(:different_vulnerability_identifier) do - vulnerability_identifiers.create!( - project_id: project.id, - external_type: 'uuid-v4', - external_id: 'uuid-v4', - fingerprint: '772da93d34a1ba010bcb5efa9fb6f8e01bafcc89', - name: 'Identifier for UUIDv4') - end - - let(:vulnerability_for_uuidv4) do - create_vulnerability!( - project_id: project.id, - author_id: user.id - ) - end - - let(:vulnerability_for_uuidv5) do - create_vulnerability!( - project_id: project.id, - author_id: user.id - ) - end - - let!(:finding1) do - create_finding!( - vulnerability_id: vulnerability_for_uuidv4.id, - project_id: project.id, - scanner_id: different_scanner.id, - primary_identifier_id: different_vulnerability_identifier.id, - location_fingerprint: 'fa18f432f1d56675f4098d318739c3cd5b14eb3e', - uuid: 'b3cc2518-5446-4dea-871c-89d5e999c1ac' - ) - end - - let!(:finding2) do - create_finding!( - vulnerability_id: vulnerability_for_uuidv5.id, - project_id: project.id, - scanner_id: scanner.id, - primary_identifier_id: vulnerability_identifier.id, - location_fingerprint: '838574be0210968bf6b9f569df9c2576242cbf0a', - uuid: '77211ed6-7dff-5f6b-8c9a-da89ad0a9b60' - ) - end - - before do - stub_const("#{described_class}::BATCH_SIZE", 1) - end - - around do |example| - freeze_time { Sidekiq::Testing.fake! { example.run } } - end - - it 'schedules background migrations', :aggregate_failures do - migrate! - - expect(BackgroundMigrationWorker.jobs.size).to eq(2) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, finding1.id, finding1.id) - expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, finding2.id, finding2.id) - end - - private - - def create_vulnerability!(project_id:, author_id:, title: 'test', severity: 7, confidence: 7, report_type: 0) - vulnerabilities.create!( - project_id: project_id, - author_id: author_id, - title: title, - severity: severity, - confidence: confidence, - report_type: report_type - ) - end - - def create_finding!( - vulnerability_id:, project_id:, scanner_id:, primary_identifier_id:, location_fingerprint:, uuid:) - vulnerabilities_findings.create!( - vulnerability_id: vulnerability_id, - project_id: project_id, - name: 'test', - severity: 7, - confidence: 7, - report_type: 0, - project_fingerprint: '123qweasdzxc', - scanner_id: scanner_id, - primary_identifier_id: primary_identifier_id, - location_fingerprint: location_fingerprint, - metadata_version: 'test', - raw_metadata: 'test', - uuid: uuid - ) - end - - def create_user!(name: "Example User", email: "user@example.com", user_type: nil) - users.create!( - name: name, - email: email, - username: name, - projects_limit: 0 - ) - end -end diff --git a/spec/migrations/update_application_settings_protected_paths_spec.rb b/spec/migrations/update_application_settings_protected_paths_spec.rb new file mode 100644 index 00000000000..21879995f1b --- /dev/null +++ b/spec/migrations/update_application_settings_protected_paths_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe UpdateApplicationSettingsProtectedPaths, :aggregate_failures do + subject(:migration) { described_class.new } + + let_it_be(:application_settings) { table(:application_settings) } + let_it_be(:oauth_paths) { %w[/oauth/authorize /oauth/token] } + let_it_be(:custom_paths) { %w[/foo /bar] } + + let(:default_paths) { application_settings.column_defaults.fetch('protected_paths') } + + before do + application_settings.create!(protected_paths: custom_paths) + application_settings.create!(protected_paths: custom_paths + oauth_paths) + application_settings.create!(protected_paths: custom_paths + oauth_paths.take(1)) + end + + describe '#up' do + before do + migrate! + application_settings.reset_column_information + end + + it 'removes the OAuth paths from the default value and persisted records' do + expect(default_paths).not_to include(*oauth_paths) + expect(default_paths).to eq(described_class::NEW_DEFAULT_PROTECTED_PATHS) + expect(application_settings.all).to all(have_attributes(protected_paths: custom_paths)) + end + end + + describe '#down' do + before do + migrate! + schema_migrate_down! + end + + it 'adds the OAuth paths to the default value and persisted records' do + expect(default_paths).to include(*oauth_paths) + expect(default_paths).to eq(described_class::OLD_DEFAULT_PROTECTED_PATHS) + expect(application_settings.all).to all(have_attributes(protected_paths: custom_paths + oauth_paths)) + end + end +end diff --git a/spec/migrations/update_invalid_member_states_spec.rb b/spec/migrations/update_invalid_member_states_spec.rb new file mode 100644 index 00000000000..802634230a9 --- /dev/null +++ b/spec/migrations/update_invalid_member_states_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe UpdateInvalidMemberStates do + let(:members) { table(:members) } + let(:groups) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:users) { table(:users) } + + before do + user = users.create!(first_name: 'Test', last_name: 'User', email: 'test@user.com', projects_limit: 1) + group = groups.create!(name: 'gitlab', path: 'gitlab-org') + project = projects.create!(namespace_id: group.id) + + members.create!(state: 2, source_id: group.id, source_type: 'Group', type: 'GroupMember', user_id: user.id, access_level: 50, notification_level: 0) + members.create!(state: 2, source_id: project.id, source_type: 'Project', type: 'ProjectMember', user_id: user.id, access_level: 50, notification_level: 0) + members.create!(state: 1, source_id: group.id, source_type: 'Group', type: 'GroupMember', user_id: user.id, access_level: 50, notification_level: 0) + members.create!(state: 0, source_id: group.id, source_type: 'Group', type: 'GroupMember', user_id: user.id, access_level: 50, notification_level: 0) + end + + it 'updates matching member record states' do + expect { migrate! } + .to change { members.where(state: 0).count }.from(1).to(3) + .and change { members.where(state: 2).count }.from(2).to(0) + .and change { members.where(state: 1).count }.by(0) + end +end diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb index 35398e29062..40bdfd4bc92 100644 --- a/spec/models/alert_management/alert_spec.rb +++ b/spec/models/alert_management/alert_spec.rb @@ -211,12 +211,6 @@ RSpec.describe AlertManagement::Alert do end end - describe '.open' do - subject { described_class.open } - - it { is_expected.to contain_exactly(acknowledged_alert, triggered_alert) } - end - describe '.not_resolved' do subject { described_class.not_resolved } @@ -324,33 +318,6 @@ RSpec.describe AlertManagement::Alert do end end - describe '.open_status?' do - using RSpec::Parameterized::TableSyntax - - where(:status, :is_open_status) do - :triggered | true - :acknowledged | true - :resolved | false - :ignored | false - nil | false - end - - with_them do - it 'returns true when the status is open status' do - expect(described_class.open_status?(status)).to eq(is_open_status) - end - end - end - - describe '#open?' do - it 'returns true when the status is open status' do - expect(triggered_alert.open?).to be true - expect(acknowledged_alert.open?).to be true - expect(resolved_alert.open?).to be false - expect(ignored_alert.open?).to be false - end - end - describe '#to_reference' do it { expect(triggered_alert.to_reference).to eq("^alert##{triggered_alert.iid}") } end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index f0212da3041..9c9a048999c 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -147,22 +147,20 @@ RSpec.describe ApplicationRecord do end end - # rubocop:disable Database/MultipleDatabases it 'increments a counter when a transaction is created in ActiveRecord' do expect(described_class.connection.transaction_open?).to be false expect(::Gitlab::Database::Metrics) .to receive(:subtransactions_increment) - .with('ActiveRecord::Base') + .with('ApplicationRecord') .once - ActiveRecord::Base.transaction do - ActiveRecord::Base.transaction(requires_new: true) do - expect(ActiveRecord::Base.connection.transaction_open?).to be true + ApplicationRecord.transaction do + ApplicationRecord.transaction(requires_new: true) do + expect(ApplicationRecord.connection.transaction_open?).to be true end end end - # rubocop:enable Database/MultipleDatabases end describe '.with_fast_read_statement_timeout' do diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 67314084c4f..0ece212d692 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -77,9 +77,24 @@ RSpec.describe ApplicationSetting do it { is_expected.to validate_numericality_of(:container_registry_cleanup_tags_service_max_list_size).only_integer.is_greater_than_or_equal_to(0) } it { is_expected.to validate_numericality_of(:container_registry_expiration_policies_worker_capacity).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:container_registry_import_max_tags_count).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:container_registry_import_max_retries).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:container_registry_import_start_max_retries).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:container_registry_import_max_step_duration).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.not_to allow_value(nil).for(:container_registry_import_max_tags_count) } + it { is_expected.not_to allow_value(nil).for(:container_registry_import_max_retries) } + it { is_expected.not_to allow_value(nil).for(:container_registry_import_start_max_retries) } + it { is_expected.not_to allow_value(nil).for(:container_registry_import_max_step_duration) } + + it { is_expected.to validate_presence_of(:container_registry_import_target_plan) } + it { is_expected.to validate_presence_of(:container_registry_import_created_before) } + it { is_expected.to validate_numericality_of(:dependency_proxy_ttl_group_policy_worker_capacity).only_integer.is_greater_than_or_equal_to(0) } it { is_expected.not_to allow_value(nil).for(:dependency_proxy_ttl_group_policy_worker_capacity) } + it { is_expected.to validate_numericality_of(:packages_cleanup_package_file_worker_capacity).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.not_to allow_value(nil).for(:packages_cleanup_package_file_worker_capacity) } + it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) } it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) } it { is_expected.to validate_presence_of(:max_artifacts_size) } @@ -126,11 +141,13 @@ RSpec.describe ApplicationSetting do it { is_expected.not_to allow_value('default' => 101).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") } it { is_expected.not_to allow_value('default' => 100, shouldntexist: 50).for(:repository_storages_weighted).with_message("can't include: shouldntexist") } - it { is_expected.to allow_value(400).for(:notes_create_limit) } - it { is_expected.not_to allow_value('two').for(:notes_create_limit) } - it { is_expected.not_to allow_value(nil).for(:notes_create_limit) } - it { is_expected.not_to allow_value(5.5).for(:notes_create_limit) } - it { is_expected.not_to allow_value(-2).for(:notes_create_limit) } + %i[notes_create_limit user_email_lookup_limit].each do |setting| + it { is_expected.to allow_value(400).for(setting) } + it { is_expected.not_to allow_value('two').for(setting) } + it { is_expected.not_to allow_value(nil).for(setting) } + it { is_expected.not_to allow_value(5.5).for(setting) } + it { is_expected.not_to allow_value(-2).for(setting) } + end def many_usernames(num = 100) Array.new(num) { |i| "username#{i}" } @@ -489,7 +506,7 @@ RSpec.describe ApplicationSetting do context 'key restrictions' do it 'supports all key types' do - expect(described_class::SUPPORTED_KEY_TYPES).to contain_exactly(:rsa, :dsa, :ecdsa, :ed25519) + expect(described_class::SUPPORTED_KEY_TYPES).to eq(Gitlab::SSHPublicKey.supported_types) end it 'does not allow all key types to be disabled' do @@ -1242,7 +1259,7 @@ RSpec.describe ApplicationSetting do end end - describe '#static_objects_external_storage_auth_token=' do + describe '#static_objects_external_storage_auth_token=', :aggregate_failures do subject { setting.static_objects_external_storage_auth_token = token } let(:token) { 'Test' } @@ -1266,5 +1283,20 @@ RSpec.describe ApplicationSetting do expect(setting.static_objects_external_storage_auth_token).to be_nil end end + + context 'with plaintext token only' do + let(:token) { '' } + + it 'ignores the plaintext token' do + subject + + ApplicationSetting.update_all(static_objects_external_storage_auth_token: 'Test') + + setting.reload + expect(setting[:static_objects_external_storage_auth_token]).to be_nil + expect(setting[:static_objects_external_storage_auth_token_encrypted]).to be_nil + expect(setting.static_objects_external_storage_auth_token).to be_nil + end + end end end diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb index 02151da583e..61caff647d6 100644 --- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb @@ -91,4 +91,10 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do end end end + + describe '#file_relations' do + it 'returns project file relations' do + expect(subject.file_relations).to contain_exactly('uploads', 'lfs_objects') + end + end end diff --git a/spec/models/ci/build_report_result_spec.rb b/spec/models/ci/build_report_result_spec.rb index e78f602feef..3f53c6c1c0e 100644 --- a/spec/models/ci/build_report_result_spec.rb +++ b/spec/models/ci/build_report_result_spec.rb @@ -5,6 +5,11 @@ require 'spec_helper' RSpec.describe Ci::BuildReportResult do let(:build_report_result) { build(:ci_build_report_result, :with_junit_success) } + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_build_report_result, project: parent) } + end + describe 'associations' do it { is_expected.to belong_to(:build) } it { is_expected.to belong_to(:project) } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index b9a12339e61..b8c5af5a911 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -565,6 +565,26 @@ RSpec.describe Ci::Build do expect(build.reload.runtime_metadata).not_to be_present end end + + context 'when a failure reason is provided' do + context 'when a failure reason is a symbol' do + it 'correctly sets a failure reason' do + build.drop!(:script_failure) + + expect(build.failure_reason).to eq 'script_failure' + end + end + + context 'when a failure reason is an object' do + it 'correctly sets a failure reason' do + reason = ::Gitlab::Ci::Build::Status::Reason.new(build, :script_failure) + + build.drop!(reason) + + expect(build.failure_reason).to eq 'script_failure' + end + end + end end describe '#schedulable?' do @@ -2002,6 +2022,16 @@ RSpec.describe Ci::Build do it { is_expected.not_to be_retryable } end + + context 'when build is waiting for deployment approval' do + subject { build_stubbed(:ci_build, :manual, environment: 'production') } + + before do + create(:deployment, :blocked, deployable: subject) + end + + it { is_expected.not_to be_retryable } + end end end @@ -2064,6 +2094,31 @@ RSpec.describe Ci::Build do end describe 'build auto retry feature' do + context 'with deployment job' do + let(:build) do + create(:ci_build, :deploy_to_production, :with_deployment, + user: user, pipeline: pipeline, project: project) + end + + before do + project.add_developer(user) + allow(build).to receive(:auto_retry_allowed?) { true } + end + + it 'creates a deployment when a build is dropped' do + expect { build.drop!(:script_failure) }.to change { Deployment.count }.by(1) + + retried_deployment = Deployment.last + expect(build.deployment.environment).to eq(retried_deployment.environment) + expect(build.deployment.ref).to eq(retried_deployment.ref) + expect(build.deployment.sha).to eq(retried_deployment.sha) + expect(build.deployment.tag).to eq(retried_deployment.tag) + expect(build.deployment.user).to eq(retried_deployment.user) + expect(build.deployment).to be_failed + expect(retried_deployment).to be_created + end + end + describe '#retries_count' do subject { create(:ci_build, name: 'test', pipeline: pipeline) } @@ -2152,6 +2207,28 @@ RSpec.describe Ci::Build do end end + describe '#auto_retry_expected?' do + subject { create(:ci_build, :failed) } + + context 'when build is failed and auto retry is configured' do + before do + allow(subject) + .to receive(:auto_retry_allowed?) + .and_return(true) + end + + it 'expects auto-retry to happen' do + expect(subject.auto_retry_expected?).to be true + end + end + + context 'when build failed by auto retry is not configured' do + it 'does not expect auto-retry to happen' do + expect(subject.auto_retry_expected?).to be false + end + end + end + describe '#artifacts_file_for_type' do let(:build) { create(:ci_build, :artifacts) } let(:file_type) { :archive } @@ -2443,6 +2520,16 @@ RSpec.describe Ci::Build do it { is_expected.not_to be_playable } end + + context 'when build is waiting for deployment approval' do + subject { build_stubbed(:ci_build, :manual, environment: 'production') } + + before do + create(:deployment, :blocked, deployable: subject) + end + + it { is_expected.not_to be_playable } + end end describe 'project settings' do @@ -2653,6 +2740,8 @@ RSpec.describe Ci::Build do { key: 'CI_DEPENDENCY_PROXY_USER', value: 'gitlab-ci-token', public: true, masked: false }, { key: 'CI_DEPENDENCY_PROXY_PASSWORD', value: 'my-token', public: false, masked: true }, { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true }, + { key: 'CI_JOB_JWT_V1', value: 'ci.job.jwt', public: false, masked: true }, + { key: 'CI_JOB_JWT_V2', value: 'ci.job.jwtv2', public: false, masked: true }, { key: 'CI_JOB_NAME', value: 'test', public: true, masked: false }, { key: 'CI_JOB_STAGE', value: 'test', public: true, masked: false }, { key: 'CI_NODE_TOTAL', value: '1', public: true, masked: false }, @@ -2720,6 +2809,7 @@ RSpec.describe Ci::Build do before do allow(Gitlab::Ci::Jwt).to receive(:for_build).and_return('ci.job.jwt') + allow(Gitlab::Ci::JwtV2).to receive(:for_build).and_return('ci.job.jwtv2') build.set_token('my-token') build.yaml_variables = [] end @@ -2771,6 +2861,8 @@ RSpec.describe Ci::Build do let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true, masked: false } } let(:dependency_proxy_var) { { key: 'dependency_proxy', value: 'value', public: true, masked: false } } let(:job_jwt_var) { { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true } } + let(:job_jwt_var_v1) { { key: 'CI_JOB_JWT_V1', value: 'ci.job.jwt', public: false, masked: true } } + let(:job_jwt_var_v2) { { key: 'CI_JOB_JWT_V2', value: 'ci.job.jwtv2', public: false, masked: true } } let(:job_dependency_var) { { key: 'job_dependency', value: 'value', public: true, masked: false } } before do @@ -2784,7 +2876,7 @@ RSpec.describe Ci::Build do allow(build).to receive(:dependency_variables) { [job_dependency_var] } allow(build).to receive(:dependency_proxy_variables) { [dependency_proxy_var] } - allow(build.project) + allow(build.pipeline.project) .to receive(:predefined_variables) { [project_pre_var] } project.variables.create!(key: 'secret', value: 'value') @@ -3084,7 +3176,7 @@ RSpec.describe Ci::Build do context 'when the branch is protected' do before do - allow(build.project).to receive(:protected_for?).with(ref).and_return(true) + allow(build.pipeline.project).to receive(:protected_for?).with(ref).and_return(true) end it { is_expected.to include(protected_variable) } @@ -3092,7 +3184,7 @@ RSpec.describe Ci::Build do context 'when the tag is protected' do before do - allow(build.project).to receive(:protected_for?).with(ref).and_return(true) + allow(build.pipeline.project).to receive(:protected_for?).with(ref).and_return(true) end it { is_expected.to include(protected_variable) } @@ -3131,7 +3223,7 @@ RSpec.describe Ci::Build do context 'when the branch is protected' do before do - allow(build.project).to receive(:protected_for?).with(ref).and_return(true) + allow(build.pipeline.project).to receive(:protected_for?).with(ref).and_return(true) end it { is_expected.to include(protected_variable) } @@ -3139,7 +3231,7 @@ RSpec.describe Ci::Build do context 'when the tag is protected' do before do - allow(build.project).to receive(:protected_for?).with(ref).and_return(true) + allow(build.pipeline.project).to receive(:protected_for?).with(ref).and_return(true) end it { is_expected.to include(protected_variable) } @@ -3526,6 +3618,20 @@ RSpec.describe Ci::Build do build.scoped_variables end + + context 'when variables builder is used' do + it 'returns the same variables' do + build.user = create(:user) + + allow(build.pipeline).to receive(:use_variables_builder_definitions?).and_return(false) + legacy_variables = build.scoped_variables.to_hash + + allow(build.pipeline).to receive(:use_variables_builder_definitions?).and_return(true) + new_variables = build.scoped_variables.to_hash + + expect(new_variables).to eq(legacy_variables) + end + end end describe '#simple_variables_without_dependencies' do @@ -3538,7 +3644,8 @@ RSpec.describe Ci::Build do shared_examples "secret CI variables" do context 'when ref is branch' do - let(:build) { create(:ci_build, ref: 'master', tag: false, project: project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, ref: 'master', tag: false, pipeline: pipeline, project: project) } context 'when ref is protected' do before do @@ -3554,7 +3661,8 @@ RSpec.describe Ci::Build do end context 'when ref is tag' do - let(:build) { create(:ci_build, ref: 'v1.1.0', tag: true, project: project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, ref: 'v1.1.0', tag: true, pipeline: pipeline, project: project) } context 'when ref is protected' do before do @@ -3652,8 +3760,6 @@ RSpec.describe Ci::Build do .and_return(project_variables) end - it { is_expected.to eq(project_variables) } - context 'environment is nil' do let(:environment) { nil } @@ -3661,6 +3767,35 @@ RSpec.describe Ci::Build do end end + describe '#user_variables' do + subject { build.user_variables.to_hash } + + context 'with user' do + let(:expected_variables) do + { + 'GITLAB_USER_EMAIL' => user.email, + 'GITLAB_USER_ID' => user.id.to_s, + 'GITLAB_USER_LOGIN' => user.username, + 'GITLAB_USER_NAME' => user.name + } + end + + before do + build.user = user + end + + it { is_expected.to eq(expected_variables) } + end + + context 'without user' do + before do + expect(build).to receive(:user).and_return(nil) + end + + it { is_expected.to be_empty } + end + end + describe '#any_unmet_prerequisites?' do let(:build) { create(:ci_build, :created) } @@ -3762,6 +3897,18 @@ RSpec.describe Ci::Build do end end + describe 'when the build is waiting for deployment approval' do + let(:build) { create(:ci_build, :manual, environment: 'production') } + + before do + create(:deployment, :blocked, deployable: build) + end + + it 'does not allow the build to be enqueued' do + expect { build.enqueue! }.to raise_error(StateMachines::InvalidTransition) + end + end + describe 'state transition: any => [:pending]' do let(:build) { create(:ci_build, :created) } @@ -5174,25 +5321,32 @@ RSpec.describe Ci::Build do .to change { build.reload.failed? } end - it 'is executed inside a transaction' do - expect(build).to receive(:drop!) - .with(:unknown_failure) - .and_raise(ActiveRecord::Rollback) - - expect(build).to receive(:conditionally_allow_failure!) - .with(1) - .and_call_original - - expect { drop_with_exit_code } - .not_to change { build.reload.allow_failure } - end - context 'when exit_code is nil' do let(:exit_code) {} it_behaves_like 'drops the build without changing allow_failure' end end + + context 'when build is configured to be retried' do + let(:options) { { retry: 3 } } + + context 'when there is an MR attached to the pipeline and a failed job todo for that MR' do + let!(:merge_request) { create(:merge_request, source_project: project, author: user, head_pipeline: pipeline) } + let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: user, target: merge_request) } + + before do + build.update!(user: user) + project.add_developer(user) + end + + it 'resolves the todo for the old failed build' do + expect do + drop_with_exit_code + end.to change { todo.reload.state }.from('pending').to('done') + end + end + end end describe '#exit_codes_defined?' do @@ -5377,7 +5531,8 @@ RSpec.describe Ci::Build do describe '#doom!' do subject { build.doom! } - let_it_be(:build) { create(:ci_build, :queued) } + let(:traits) { [] } + let(:build) { create(:ci_build, *traits, pipeline: pipeline) } it 'updates status and failure_reason', :aggregate_failures do subject @@ -5386,10 +5541,33 @@ RSpec.describe Ci::Build do expect(build.failure_reason).to eq("data_integrity_failure") end - it 'drops associated pending build' do + it 'logs a message' do + expect(Gitlab::AppLogger) + .to receive(:info) + .with(a_hash_including(message: 'Build doomed', class: build.class.name, build_id: build.id)) + .and_call_original + subject + end + + context 'with queued builds' do + let(:traits) { [:queued] } + + it 'drops associated pending build' do + subject - expect(build.reload.queuing_entry).not_to be_present + expect(build.reload.queuing_entry).not_to be_present + end + end + + context 'with running builds' do + let(:traits) { [:picked] } + + it 'drops associated runtime metadata' do + subject + + expect(build.reload.runtime_metadata).not_to be_present + end end end diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index b6e128c317c..31c7c7a44bc 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -49,9 +49,8 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git end context 'FastDestroyAll' do - let(:parent) { create(:project) } - let(:pipeline) { create(:ci_pipeline, project: parent) } - let!(:build) { create(:ci_build, :running, :trace_live, pipeline: pipeline, project: parent) } + let(:pipeline) { create(:ci_pipeline) } + let!(:build) { create(:ci_build, :running, :trace_live, pipeline: pipeline) } let(:subjects) { build.trace_chunks } describe 'Forbid #destroy and #destroy_all' do @@ -84,7 +83,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git expect(external_data_counter).to be > 0 expect(subjects.count).to be > 0 - expect { parent.destroy! }.not_to raise_error + expect { pipeline.destroy! }.not_to raise_error expect(subjects.count).to eq(0) expect(external_data_counter).to eq(0) @@ -830,7 +829,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git expect(described_class.count).to eq(3) - subject + expect(subject).to be_truthy expect(described_class.count).to eq(0) @@ -852,7 +851,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git context 'when project is destroyed' do let(:subject) do - project.destroy! + Projects::DestroyService.new(project, project.owner).execute end it_behaves_like 'deletes all build_trace_chunk and data in redis' diff --git a/spec/models/ci/daily_build_group_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb index acc87c61036..43ba4c32477 100644 --- a/spec/models/ci/daily_build_group_report_result_spec.rb +++ b/spec/models/ci/daily_build_group_report_result_spec.rb @@ -164,4 +164,16 @@ RSpec.describe Ci::DailyBuildGroupReportResult do end end end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) { create(:ci_daily_build_group_report_result) } + + let!(:parent) { model.group } + end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) { create(:ci_daily_build_group_report_result) } + + let!(:parent) { model.project } + end end diff --git a/spec/models/ci/freeze_period_spec.rb b/spec/models/ci/freeze_period_spec.rb index f7f840c6696..b9bf1657e28 100644 --- a/spec/models/ci/freeze_period_spec.rb +++ b/spec/models/ci/freeze_period_spec.rb @@ -5,6 +5,11 @@ require 'spec_helper' RSpec.describe Ci::FreezePeriod, type: :model do subject { build(:ci_freeze_period) } + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_freeze_period, project: parent) } + end + let(:invalid_cron) { '0 0 0 * *' } it { is_expected.to belong_to(:project) } diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb index f0eec549da7..4cb3b9eef0c 100644 --- a/spec/models/ci/group_variable_spec.rb +++ b/spec/models/ci/group_variable_spec.rb @@ -42,4 +42,10 @@ RSpec.describe Ci::GroupVariable do end end end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) { create(:ci_group_variable) } + + let!(:parent) { model.group } + end end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index 38061e0975f..2e8c41b410a 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -143,6 +143,17 @@ RSpec.describe Ci::JobArtifact do end end + describe '.erasable_file_types' do + subject { described_class.erasable_file_types } + + it 'returns a list of erasable file types' do + all_types = described_class.file_types.keys + erasable_types = all_types - described_class::NON_ERASABLE_FILE_TYPES + + expect(subject).to contain_exactly(*erasable_types) + end + end + describe '.erasable' do subject { described_class.erasable } @@ -534,20 +545,8 @@ RSpec.describe Ci::JobArtifact do context 'when the artifact is a trace' do let(:file_type) { :trace } - context 'when ci_store_trace_outside_transaction is enabled' do - it 'returns true' do - expect(artifact.store_after_commit?).to be_truthy - end - end - - context 'when ci_store_trace_outside_transaction is disabled' do - before do - stub_feature_flags(ci_store_trace_outside_transaction: false) - end - - it 'returns false' do - expect(artifact.store_after_commit?).to be_falsey - end + it 'returns true' do + expect(artifact.store_after_commit?).to be_truthy end end diff --git a/spec/models/ci/job_token/project_scope_link_spec.rb b/spec/models/ci/job_token/project_scope_link_spec.rb index dd6a75dfd89..8d7bb44bd16 100644 --- a/spec/models/ci/job_token/project_scope_link_spec.rb +++ b/spec/models/ci/job_token/project_scope_link_spec.rb @@ -9,6 +9,11 @@ RSpec.describe Ci::JobToken::ProjectScopeLink do let_it_be(:project) { create(:project) } + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:user) } + let!(:model) { create(:ci_job_token_project_scope_link, added_by: parent) } + end + describe 'unique index' do let!(:link) { create(:ci_job_token_project_scope_link) } diff --git a/spec/models/ci/namespace_mirror_spec.rb b/spec/models/ci/namespace_mirror_spec.rb index b4c71f51377..a9d916115fc 100644 --- a/spec/models/ci/namespace_mirror_spec.rb +++ b/spec/models/ci/namespace_mirror_spec.rb @@ -8,50 +8,91 @@ RSpec.describe Ci::NamespaceMirror do let!(:group3) { create(:group, parent: group2) } let!(:group4) { create(:group, parent: group3) } - describe '.sync!' do - let!(:event) { namespace.sync_events.create! } + before do + # refreshing ci mirrors according to the parent tree above + Namespaces::SyncEvent.find_each { |event| Ci::NamespaceMirror.sync!(event) } + + # checking initial situation. we need to reload to reflect the changes of event sync + expect(group1.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id]) + expect(group2.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id]) + expect(group3.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id, group3.id]) + expect(group4.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id, group3.id, group4.id]) + end + + context 'scopes' do + describe '.contains_namespace' do + let_it_be(:another_group) { create(:group) } + + subject(:result) { described_class.contains_namespace(group2.id) } + + it 'returns groups having group2.id in traversal_ids' do + expect(result.pluck(:namespace_id)).to contain_exactly(group2.id, group3.id, group4.id) + end + end + + describe '.contains_any_of_namespaces' do + let!(:other_group1) { create(:group) } + let!(:other_group2) { create(:group, parent: other_group1) } + let!(:other_group3) { create(:group, parent: other_group2) } + + subject(:result) { described_class.contains_any_of_namespaces([group2.id, other_group2.id]) } + + it 'returns groups having group2.id in traversal_ids' do + expect(result.pluck(:namespace_id)).to contain_exactly( + group2.id, group3.id, group4.id, other_group2.id, other_group3.id + ) + end + end + + describe '.by_namespace_id' do + subject(:result) { described_class.by_namespace_id(group2.id) } + + it 'returns namesapce mirrors of namespace id' do + expect(result).to contain_exactly(group2.ci_namespace_mirror) + end + end + end - subject(:sync) { described_class.sync!(event.reload) } + describe '.sync!' do + subject(:sync) { described_class.sync!(Namespaces::SyncEvent.last) } - context 'when namespace hierarchy does not exist in the first place' do + context 'when namespace mirror does not exist in the first place' do let(:namespace) { group3 } - it 'creates the hierarchy' do - expect { sync }.to change { described_class.count }.from(0).to(1) + before do + namespace.ci_namespace_mirror.destroy! + namespace.sync_events.create! + end + + it 'creates the mirror' do + expect { sync }.to change { described_class.count }.from(3).to(4) - expect(namespace.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id, group3.id]) + expect(namespace.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id, group3.id]) end end - context 'when namespace hierarchy does already exist' do + context 'when namespace mirror does already exist' do let(:namespace) { group3 } before do - described_class.create!(namespace: namespace, traversal_ids: [namespace.id]) + namespace.sync_events.create! end - it 'updates the hierarchy' do + it 'updates the mirror' do expect { sync }.not_to change { described_class.count } - expect(namespace.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id, group3.id]) + expect(namespace.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id, group2.id, group3.id]) end end - # I did not extract this context to a `shared_context` because the behavior will change - # after implementing the TODO in `Ci::NamespaceMirror.sync!` - context 'changing the middle namespace' do + shared_context 'changing the middle namespace' do let(:namespace) { group2 } before do - described_class.create!(namespace_id: group1.id, traversal_ids: [group1.id]) - described_class.create!(namespace_id: group2.id, traversal_ids: [group1.id, group2.id]) - described_class.create!(namespace_id: group3.id, traversal_ids: [group1.id, group2.id, group3.id]) - described_class.create!(namespace_id: group4.id, traversal_ids: [group1.id, group2.id, group3.id, group4.id]) - - group2.update!(parent: nil) + group2.update!(parent: nil) # creates a sync event end - it 'updates hierarchies for the base but wait for events for the children' do + it 'updates traversal_ids for the base and descendants' do expect { sync }.not_to change { described_class.count } expect(group1.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id]) @@ -61,6 +102,8 @@ RSpec.describe Ci::NamespaceMirror do end end + it_behaves_like 'changing the middle namespace' + context 'when the FFs sync_traversal_ids, use_traversal_ids and use_traversal_ids_for_ancestors are disabled' do before do stub_feature_flags(sync_traversal_ids: false, @@ -68,27 +111,7 @@ RSpec.describe Ci::NamespaceMirror do use_traversal_ids_for_ancestors: false) end - context 'changing the middle namespace' do - let(:namespace) { group2 } - - before do - described_class.create!(namespace_id: group1.id, traversal_ids: [group1.id]) - described_class.create!(namespace_id: group2.id, traversal_ids: [group1.id, group2.id]) - described_class.create!(namespace_id: group3.id, traversal_ids: [group1.id, group2.id, group3.id]) - described_class.create!(namespace_id: group4.id, traversal_ids: [group1.id, group2.id, group3.id, group4.id]) - - group2.update!(parent: nil) - end - - it 'updates hierarchies for the base and descendants' do - expect { sync }.not_to change { described_class.count } - - expect(group1.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group1.id]) - expect(group2.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group2.id]) - expect(group3.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group2.id, group3.id]) - expect(group4.reload.ci_namespace_mirror).to have_attributes(traversal_ids: [group2.id, group3.id, group4.id]) - end - end + it_behaves_like 'changing the middle namespace' end end end diff --git a/spec/models/ci/pending_build_spec.rb b/spec/models/ci/pending_build_spec.rb index abf0fb443bb..5692444339f 100644 --- a/spec/models/ci/pending_build_spec.rb +++ b/spec/models/ci/pending_build_spec.rb @@ -223,4 +223,14 @@ RSpec.describe Ci::PendingBuild do end end end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:namespace) } + let!(:model) { create(:ci_pending_build, namespace: parent) } + end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_pending_build, project: parent) } + end end diff --git a/spec/models/ci/pipeline_artifact_spec.rb b/spec/models/ci/pipeline_artifact_spec.rb index f65483d2290..801505f0231 100644 --- a/spec/models/ci/pipeline_artifact_spec.rb +++ b/spec/models/ci/pipeline_artifact_spec.rb @@ -215,4 +215,11 @@ RSpec.describe Ci::PipelineArtifact, type: :model do end end end + + context 'loose foreign key on ci_pipeline_artifacts.project_id' do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_pipeline_artifact, project: parent) } + end + end end diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index fee74f8f674..0f1cb721e95 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -23,6 +23,11 @@ RSpec.describe Ci::PipelineSchedule do subject { build(:ci_pipeline_schedule, project: project) } end + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:user) } + let!(:model) { create(:ci_pipeline_schedule, owner: parent) } + end + describe 'validations' do it 'does not allow invalid cron patterns' do pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *') diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index fd9970699d7..90f56c1e0a4 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -31,6 +31,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it { is_expected.to have_many(:statuses_order_id_desc) } it { is_expected.to have_many(:bridges) } it { is_expected.to have_many(:job_artifacts).through(:builds) } + it { is_expected.to have_many(:build_trace_chunks).through(:builds) } it { is_expected.to have_many(:auto_canceled_pipelines) } it { is_expected.to have_many(:auto_canceled_jobs) } it { is_expected.to have_many(:sourced_pipelines) } @@ -1516,30 +1517,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do end describe 'pipeline caching' do - context 'when expire_job_and_pipeline_cache_synchronously is enabled' do - before do - stub_feature_flags(expire_job_and_pipeline_cache_synchronously: true) - end - - it 'executes Ci::ExpirePipelineCacheService' do - expect_next_instance_of(Ci::ExpirePipelineCacheService) do |service| - expect(service).to receive(:execute).with(pipeline) - end - - pipeline.cancel + it 'executes Ci::ExpirePipelineCacheService' do + expect_next_instance_of(Ci::ExpirePipelineCacheService) do |service| + expect(service).to receive(:execute).with(pipeline) end - end - - context 'when expire_job_and_pipeline_cache_synchronously is disabled' do - before do - stub_feature_flags(expire_job_and_pipeline_cache_synchronously: false) - end - - it 'performs ExpirePipelinesCacheWorker' do - expect(ExpirePipelineCacheWorker).to receive(:perform_async).with(pipeline.id) - pipeline.cancel - end + pipeline.cancel end end @@ -4677,4 +4660,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do let!(:model) { create(:ci_pipeline, user: create(:user)) } let!(:parent) { model.user } end + + describe 'tags count' do + let_it_be_with_refind(:pipeline) do + create(:ci_empty_pipeline, project: project) + end + + it { expect(pipeline.tags_count).to eq(0) } + it { expect(pipeline.distinct_tags_count).to eq(0) } + + context 'with builds' do + before do + create(:ci_build, pipeline: pipeline, tag_list: %w[a b]) + create(:ci_build, pipeline: pipeline, tag_list: %w[b c]) + end + + it { expect(pipeline.tags_count).to eq(4) } + it { expect(pipeline.distinct_tags_count).to eq(3) } + end + end end diff --git a/spec/models/ci/project_mirror_spec.rb b/spec/models/ci/project_mirror_spec.rb index 199285b036c..5ef520b4230 100644 --- a/spec/models/ci/project_mirror_spec.rb +++ b/spec/models/ci/project_mirror_spec.rb @@ -8,12 +8,36 @@ RSpec.describe Ci::ProjectMirror do let!(:project) { create(:project, namespace: group2) } + context 'scopes' do + let_it_be(:another_project) { create(:project, namespace: group1) } + + describe '.by_project_id' do + subject(:result) { described_class.by_project_id(project.id) } + + it 'returns project mirrors of project' do + expect(result.pluck(:project_id)).to contain_exactly(project.id) + end + end + + describe '.by_namespace_id' do + subject(:result) { described_class.by_namespace_id(group2.id) } + + it 'returns project mirrors of namespace id' do + expect(result).to contain_exactly(project.ci_project_mirror) + end + end + end + describe '.sync!' do let!(:event) { Projects::SyncEvent.create!(project: project) } - subject(:sync) { described_class.sync!(event.reload) } + subject(:sync) { described_class.sync!(event) } + + context 'when project mirror does not exist in the first place' do + before do + project.ci_project_mirror.destroy! + end - context 'when project hierarchy does not exist in the first place' do it 'creates a ci_projects record' do expect { sync }.to change { described_class.count }.from(0).to(1) @@ -21,11 +45,7 @@ RSpec.describe Ci::ProjectMirror do end end - context 'when project hierarchy does already exist' do - before do - described_class.create!(project_id: project.id, namespace_id: group1.id) - end - + context 'when project mirror does already exist' do it 'updates the related ci_projects record' do expect { sync }.not_to change { described_class.count } diff --git a/spec/models/ci/resource_group_spec.rb b/spec/models/ci/resource_group_spec.rb index aae16157fbf..76e74f3193c 100644 --- a/spec/models/ci/resource_group_spec.rb +++ b/spec/models/ci/resource_group_spec.rb @@ -3,6 +3,11 @@ require 'spec_helper' RSpec.describe Ci::ResourceGroup do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_resource_group, project: parent) } + end + describe 'validation' do it 'valids when key includes allowed character' do resource_group = build(:ci_resource_group, key: 'test') diff --git a/spec/models/ci/runner_namespace_spec.rb b/spec/models/ci/runner_namespace_spec.rb index 41d805adb9f..2d1fe11147c 100644 --- a/spec/models/ci/runner_namespace_spec.rb +++ b/spec/models/ci/runner_namespace_spec.rb @@ -6,4 +6,10 @@ RSpec.describe Ci::RunnerNamespace do it_behaves_like 'includes Limitable concern' do subject { build(:ci_runner_namespace, group: create(:group, :nested), runner: create(:ci_runner, :group)) } end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) { create(:ci_runner_namespace) } + + let!(:parent) { model.namespace } + end end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 5142f70fa2c..6830a8daa3b 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -48,7 +48,7 @@ RSpec.describe Ci::Runner do let(:runner) { create(:ci_runner, :group, groups: [group]) } it 'disallows assigning group if already assigned to a group' do - runner.runner_namespaces << build(:ci_runner_namespace) + runner.runner_namespaces << create(:ci_runner_namespace) expect(runner).not_to be_valid expect(runner.errors.full_messages).to include('Runner needs to be assigned to exactly one group') @@ -203,28 +203,56 @@ RSpec.describe Ci::Runner do end end - describe '.belonging_to_parent_group_of_project' do - let(:project) { create(:project, group: group) } - let(:group) { create(:group) } - let(:runner) { create(:ci_runner, :group, groups: [group]) } - let!(:unrelated_group) { create(:group) } - let!(:unrelated_project) { create(:project, group: unrelated_group) } - let!(:unrelated_runner) { create(:ci_runner, :group, groups: [unrelated_group]) } + shared_examples '.belonging_to_parent_group_of_project' do + let!(:group1) { create(:group) } + let!(:project1) { create(:project, group: group1) } + let!(:runner1) { create(:ci_runner, :group, groups: [group1]) } + + let!(:group2) { create(:group) } + let!(:project2) { create(:project, group: group2) } + let!(:runner2) { create(:ci_runner, :group, groups: [group2]) } + + let(:project_id) { project1.id } + + subject(:result) { described_class.belonging_to_parent_group_of_project(project_id) } it 'returns the specific group runner' do - expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner) + expect(result).to contain_exactly(runner1) end - context 'with a parent group with a runner' do - let(:runner) { create(:ci_runner, :group, groups: [parent_group]) } - let(:project) { create(:project, group: group) } - let(:group) { create(:group, parent: parent_group) } - let(:parent_group) { create(:group) } + context 'with a parent group with a runner', :sidekiq_inline do + before do + group1.update!(parent: group2) + end - it 'returns the group runner from the parent group' do - expect(described_class.belonging_to_parent_group_of_project(project.id)).to contain_exactly(runner) + it 'returns the group runner from the group and the parent group' do + expect(result).to contain_exactly(runner1, runner2) end end + + context 'with multiple project ids' do + let(:project_id) { [project1.id, project2.id] } + + it 'raises ArgumentError' do + expect { result }.to raise_error(ArgumentError) + end + end + end + + context 'when use_traversal_ids* are enabled' do + it_behaves_like '.belonging_to_parent_group_of_project' + end + + context 'when use_traversal_ids* are disabled' do + before do + stub_feature_flags( + use_traversal_ids: false, + use_traversal_ids_for_ancestors: false, + use_traversal_ids_for_ancestor_scopes: false + ) + end + + it_behaves_like '.belonging_to_parent_group_of_project' end describe '.owned_or_instance_wide' do @@ -1358,7 +1386,7 @@ RSpec.describe Ci::Runner do it { is_expected.to eq(contacted_at_stored) } end - describe '.belonging_to_group' do + describe '.legacy_belonging_to_group' do shared_examples 'returns group runners' do it 'returns the specific group runner' do group = create(:group) @@ -1366,7 +1394,7 @@ RSpec.describe Ci::Runner do unrelated_group = create(:group) create(:ci_runner, :group, groups: [unrelated_group]) - expect(described_class.belonging_to_group(group.id)).to contain_exactly(runner) + expect(described_class.legacy_belonging_to_group(group.id)).to contain_exactly(runner) end context 'runner belonging to parent group' do @@ -1376,13 +1404,13 @@ RSpec.describe Ci::Runner do context 'when include_parent option is passed' do it 'returns the group runner from the parent group' do - expect(described_class.belonging_to_group(group.id, include_ancestors: true)).to contain_exactly(parent_runner) + expect(described_class.legacy_belonging_to_group(group.id, include_ancestors: true)).to contain_exactly(parent_runner) end end context 'when include_parent option is not passed' do it 'does not return the group runner from the parent group' do - expect(described_class.belonging_to_group(group.id)).to be_empty + expect(described_class.legacy_belonging_to_group(group.id)).to be_empty end end end @@ -1398,4 +1426,48 @@ RSpec.describe Ci::Runner do it_behaves_like 'returns group runners' end end + + describe '.belonging_to_group' do + it 'returns the specific group runner' do + group = create(:group) + runner = create(:ci_runner, :group, groups: [group]) + unrelated_group = create(:group) + create(:ci_runner, :group, groups: [unrelated_group]) + + expect(described_class.belonging_to_group(group.id)).to contain_exactly(runner) + end + end + + describe '.belonging_to_group_and_ancestors' do + let_it_be(:parent_group) { create(:group) } + let_it_be(:parent_runner) { create(:ci_runner, :group, groups: [parent_group]) } + let_it_be(:group) { create(:group, parent: parent_group) } + + it 'returns the group runner from the parent group' do + expect(described_class.belonging_to_group_and_ancestors(group.id)).to contain_exactly(parent_runner) + end + end + + describe '.belonging_to_group_or_project_descendants' do + it 'returns the specific group runners' do + group1 = create(:group) + group2 = create(:group, parent: group1) + group3 = create(:group) + + project1 = create(:project, namespace: group1) + project2 = create(:project, namespace: group2) + project3 = create(:project, namespace: group3) + + runner1 = create(:ci_runner, :group, groups: [group1]) + runner2 = create(:ci_runner, :group, groups: [group2]) + _runner3 = create(:ci_runner, :group, groups: [group3]) + runner4 = create(:ci_runner, :project, projects: [project1]) + runner5 = create(:ci_runner, :project, projects: [project2]) + _runner6 = create(:ci_runner, :project, projects: [project3]) + + expect(described_class.belonging_to_group_or_project_descendants(group1.id)).to contain_exactly( + runner1, runner2, runner4, runner5 + ) + end + end end diff --git a/spec/models/ci/running_build_spec.rb b/spec/models/ci/running_build_spec.rb index 629861e35b8..d2f74494308 100644 --- a/spec/models/ci/running_build_spec.rb +++ b/spec/models/ci/running_build_spec.rb @@ -49,4 +49,9 @@ RSpec.describe Ci::RunningBuild do end end end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_running_build, project: parent) } + end end diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb new file mode 100644 index 00000000000..ae57b63e7a4 --- /dev/null +++ b/spec/models/ci/secure_file_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::SecureFile do + let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') } + + subject { create(:ci_secure_file) } + + before do + stub_ci_secure_file_object_storage + end + + it { is_expected.to be_a FileStoreMounter } + + it { is_expected.to belong_to(:project).required } + + it_behaves_like 'having unique enum values' + + describe 'validations' do + it { is_expected.to validate_presence_of(:checksum) } + it { is_expected.to validate_presence_of(:file_store) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:permissions) } + it { is_expected.to validate_presence_of(:project_id) } + end + + describe '#permissions' do + it 'defaults to read_only file permssions' do + expect(subject.permissions).to eq('read_only') + end + end + + describe '#checksum' do + it 'computes SHA256 checksum on the file before encrypted' do + subject.file = CarrierWaveStringFile.new(sample_file) + subject.save! + expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file)) + end + end + + describe '#checksum_algorithm' do + it 'returns the configured checksum_algorithm' do + expect(subject.checksum_algorithm).to eq('sha256') + end + end + + describe '#file' do + it 'returns the saved file' do + subject.file = CarrierWaveStringFile.new(sample_file) + subject.save! + expect(Base64.encode64(subject.file.read)).to eq(Base64.encode64(sample_file)) + end + end +end diff --git a/spec/models/ci/unit_test_spec.rb b/spec/models/ci/unit_test_spec.rb index 2207a362be3..556cf93c266 100644 --- a/spec/models/ci/unit_test_spec.rb +++ b/spec/models/ci/unit_test_spec.rb @@ -3,6 +3,11 @@ require 'spec_helper' RSpec.describe Ci::UnitTest do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:project) } + let!(:model) { create(:ci_unit_test, project: parent) } + end + describe 'relationships' do it { is_expected.to belong_to(:project) } it { is_expected.to have_many(:unit_test_failures) } diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index 3b521086c14..f279e779de5 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -76,12 +76,12 @@ RSpec.describe Clusters::Agent do end end - describe '#active?' do + describe '#connected?' do let_it_be(:agent) { create(:cluster_agent) } let!(:token) { create(:cluster_agent_token, agent: agent, last_used_at: last_used_at) } - subject { agent.active? } + subject { agent.connected? } context 'agent has never connected' do let(:last_used_at) { nil } @@ -99,6 +99,14 @@ RSpec.describe Clusters::Agent do let(:last_used_at) { 2.minutes.ago } it { is_expected.to be_truthy } + + context 'agent token has been revoked' do + before do + token.revoked! + end + + it { is_expected.to be_falsey } + end end context 'agent has multiple tokens' do @@ -108,4 +116,19 @@ RSpec.describe Clusters::Agent do it { is_expected.to be_truthy } end end + + describe '#activity_event_deletion_cutoff' do + let_it_be(:agent) { create(:cluster_agent) } + let_it_be(:event1) { create(:agent_activity_event, agent: agent, recorded_at: 1.hour.ago) } + let_it_be(:event2) { create(:agent_activity_event, agent: agent, recorded_at: 2.hours.ago) } + let_it_be(:event3) { create(:agent_activity_event, agent: agent, recorded_at: 3.hours.ago) } + + subject { agent.activity_event_deletion_cutoff } + + before do + stub_const("#{described_class}::ACTIVITY_EVENT_LIMIT", 2) + end + + it { is_expected.to be_like_time(event2.recorded_at) } + end end diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb index ad9f948224f..efa2a3eb09b 100644 --- a/spec/models/clusters/agent_token_spec.rb +++ b/spec/models/clusters/agent_token_spec.rb @@ -9,17 +9,29 @@ RSpec.describe Clusters::AgentToken do it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_presence_of(:name) } + it_behaves_like 'having unique enum values' + describe 'scopes' do describe '.order_last_used_at_desc' do - let_it_be(:token_1) { create(:cluster_agent_token, last_used_at: 7.days.ago) } - let_it_be(:token_2) { create(:cluster_agent_token, last_used_at: nil) } - let_it_be(:token_3) { create(:cluster_agent_token, last_used_at: 2.days.ago) } + let_it_be(:agent) { create(:cluster_agent) } + let_it_be(:token_1) { create(:cluster_agent_token, agent: agent, last_used_at: 7.days.ago) } + let_it_be(:token_2) { create(:cluster_agent_token, agent: agent, last_used_at: nil) } + let_it_be(:token_3) { create(:cluster_agent_token, agent: agent, last_used_at: 2.days.ago) } it 'sorts by last_used_at descending, with null values at last' do expect(described_class.order_last_used_at_desc) .to eq([token_3, token_1, token_2]) end end + + describe '.with_status' do + let!(:active_token) { create(:cluster_agent_token) } + let!(:revoked_token) { create(:cluster_agent_token, :revoked) } + + subject { described_class.with_status(:active) } + + it { is_expected.to contain_exactly(active_token) } + end end describe '#token' do @@ -37,83 +49,4 @@ RSpec.describe Clusters::AgentToken do expect(agent_token.token.length).to be >= 50 end end - - describe '#track_usage', :clean_gitlab_redis_cache do - let_it_be(:agent) { create(:cluster_agent) } - - let(:agent_token) { create(:cluster_agent_token, agent: agent) } - - subject { agent_token.track_usage } - - context 'when last_used_at was updated recently' do - before do - agent_token.update!(last_used_at: 10.minutes.ago) - end - - it 'updates cache but not database' do - expect { subject }.not_to change { agent_token.reload.read_attribute(:last_used_at) } - - expect_redis_update - end - end - - context 'when last_used_at was not updated recently' do - it 'updates cache and database' do - does_db_update - expect_redis_update - end - - context 'with invalid token' do - before do - agent_token.description = SecureRandom.hex(2000) - end - - it 'still updates caches and database' do - expect(agent_token).to be_invalid - - does_db_update - expect_redis_update - end - end - - context 'agent is inactive' do - before do - allow(agent).to receive(:active?).and_return(false) - end - - it 'creates an activity event' do - expect { subject }.to change { agent.activity_events.count } - - event = agent.activity_events.last - expect(event).to have_attributes( - kind: 'agent_connected', - level: 'info', - recorded_at: agent_token.reload.read_attribute(:last_used_at), - agent_token: agent_token - ) - end - end - - context 'agent is active' do - before do - allow(agent).to receive(:active?).and_return(true) - end - - it 'does not create an activity event' do - expect { subject }.not_to change { agent.activity_events.count } - end - end - end - - def expect_redis_update - Gitlab::Redis::Cache.with do |redis| - redis_key = "cache:#{described_class.name}:#{agent_token.id}:attributes" - expect(redis.get(redis_key)).to be_present - end - end - - def does_db_update - expect { subject }.to change { agent_token.reload.read_attribute(:last_used_at) } - end - end end diff --git a/spec/models/clusters/agents/activity_event_spec.rb b/spec/models/clusters/agents/activity_event_spec.rb index 18b9c82fa6a..2e3833898fd 100644 --- a/spec/models/clusters/agents/activity_event_spec.rb +++ b/spec/models/clusters/agents/activity_event_spec.rb @@ -16,11 +16,10 @@ RSpec.describe Clusters::Agents::ActivityEvent do let_it_be(:agent) { create(:cluster_agent) } describe '.in_timeline_order' do - let(:recorded_at) { 1.hour.ago } - - let!(:event1) { create(:agent_activity_event, agent: agent, recorded_at: recorded_at) } - let!(:event2) { create(:agent_activity_event, agent: agent, recorded_at: Time.current) } - let!(:event3) { create(:agent_activity_event, agent: agent, recorded_at: recorded_at) } + let_it_be(:recorded_at) { 1.hour.ago } + let_it_be(:event1) { create(:agent_activity_event, agent: agent, recorded_at: recorded_at) } + let_it_be(:event2) { create(:agent_activity_event, agent: agent, recorded_at: Time.current) } + let_it_be(:event3) { create(:agent_activity_event, agent: agent, recorded_at: recorded_at) } subject { described_class.in_timeline_order } @@ -28,5 +27,19 @@ RSpec.describe Clusters::Agents::ActivityEvent do is_expected.to eq([event2, event3, event1]) end end + + describe '.recorded_before' do + let_it_be(:event1) { create(:agent_activity_event, agent: agent, recorded_at: 1.hour.ago) } + let_it_be(:event2) { create(:agent_activity_event, agent: agent, recorded_at: 2.hours.ago) } + let_it_be(:event3) { create(:agent_activity_event, agent: agent, recorded_at: 3.hours.ago) } + + let(:cutoff) { event2.recorded_at } + + subject { described_class.recorded_before(cutoff) } + + it 'returns only events recorded before the cutoff' do + is_expected.to contain_exactly(event3) + end + end end end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index 434d7ad4a90..8f02161843b 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -101,19 +101,6 @@ RSpec.describe Clusters::Applications::Runner do end end - describe '#prepare_uninstall' do - it 'pauses associated runner' do - active_runner = create(:ci_runner, contacted_at: 1.second.ago) - - expect(active_runner.active).to be_truthy - - application_runner = create(:clusters_applications_runner, :scheduled, runner: active_runner) - application_runner.prepare_uninstall - - expect(active_runner.active).to be_falsey - end - end - describe '#make_uninstalling!' do subject { create(:clusters_applications_runner, :scheduled, runner: ci_runner) } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 665a2a936af..d5e74d36b58 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -46,28 +46,10 @@ RSpec.describe CommitStatus do describe 'status state machine' do let!(:commit_status) { create(:commit_status, :running, project: project) } - context 'when expire_job_and_pipeline_cache_synchronously is enabled' do - before do - stub_feature_flags(expire_job_and_pipeline_cache_synchronously: true) - end - - it 'invalidates the cache after a transition' do - expect(commit_status).to receive(:expire_etag_cache!) + it 'invalidates the cache after a transition' do + expect(commit_status).to receive(:expire_etag_cache!) - commit_status.success! - end - end - - context 'when expire_job_and_pipeline_cache_synchronously is disabled' do - before do - stub_feature_flags(expire_job_and_pipeline_cache_synchronously: false) - end - - it 'invalidates the cache after a transition' do - expect(ExpireJobCacheWorker).to receive(:perform_async).with(commit_status.id) - - commit_status.success! - end + commit_status.success! end describe 'transitioning to running' do @@ -773,6 +755,26 @@ RSpec.describe CommitStatus do expect { commit_status.drop! }.to change { commit_status.status }.from('manual').to('failed') end end + + context 'when a failure reason is provided' do + context 'when a failure reason is a symbol' do + it 'correctly sets a failure reason' do + commit_status.drop!(:script_failure) + + expect(commit_status).to be_script_failure + end + end + + context 'when a failure reason is an object' do + it 'correctly sets a failure reason' do + reason = ::Gitlab::Ci::Build::Status::Reason.new(commit_status, :script_failure) + + commit_status.drop!(reason) + + expect(commit_status).to be_script_failure + end + end + end end describe 'ensure stage assignment' do @@ -961,18 +963,17 @@ RSpec.describe CommitStatus do describe '.bulk_insert_tags!' do let(:statuses) { double('statuses') } - let(:tag_list_by_build) { double('tag list') } let(:inserter) { double('inserter') } it 'delegates to bulk insert class' do expect(Gitlab::Ci::Tags::BulkInsert) .to receive(:new) - .with(statuses, tag_list_by_build) + .with(statuses) .and_return(inserter) expect(inserter).to receive(:insert!) - described_class.bulk_insert_tags!(statuses, tag_list_by_build) + described_class.bulk_insert_tags!(statuses) end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 2a3f639a8ac..e9c3d1dc646 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -922,6 +922,22 @@ RSpec.describe Issuable do end end + describe '#supports_escalation?' do + where(:issuable_type, :supports_escalation) do + :issue | false + :incident | true + :merge_request | false + end + + with_them do + let(:issuable) { build_stubbed(issuable_type) } + + subject { issuable.supports_escalation? } + + it { is_expected.to eq(supports_escalation) } + end + end + describe '#incident?' do where(:issuable_type, :incident) do :issue | false diff --git a/spec/models/concerns/participable_spec.rb b/spec/models/concerns/participable_spec.rb index 50cf7377b99..99a3a0fb79a 100644 --- a/spec/models/concerns/participable_spec.rb +++ b/spec/models/concerns/participable_spec.rb @@ -138,7 +138,7 @@ RSpec.describe Participable do allow(instance).to receive_message_chain(:model_name, :element) { 'class' } expect(instance).to receive(:foo).and_return(user2) expect(instance).to receive(:bar).and_return(user3) - expect(instance).to receive(:project).thrice.and_return(project) + expect(instance).to receive(:project).twice.and_return(project) participants = instance.visible_participants(user1) @@ -159,31 +159,10 @@ RSpec.describe Participable do allow(instance).to receive_message_chain(:model_name, :element) { 'class' } allow(instance).to receive(:bar).and_return(user2) - expect(instance).to receive(:project).thrice.and_return(project) + expect(instance).to receive(:project).twice.and_return(project) expect(instance.visible_participants(user1)).to be_empty end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(verify_participants_access: false) - end - - it 'returns unavailable participants' do - model.participant(:bar) - - instance = model.new - user1 = build(:user) - user2 = build(:user) - project = build(:project, :public) - - allow(instance).to receive_message_chain(:model_name, :element) { 'class' } - allow(instance).to receive(:bar).and_return(user2) - expect(instance).to receive(:project).thrice.and_return(project) - - expect(instance.visible_participants(user1)).to match_array([user2]) - end - end end end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 2330147b376..cf66ba83e87 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -141,6 +141,11 @@ RSpec.describe Group, 'Routable', :with_clean_rails_cache do end end + it 'creates route with namespace referencing group' do + expect(group.route).not_to be_nil + expect(group.route.namespace).to eq(group) + end + describe '.where_full_path_in' do context 'without any paths' do it 'returns an empty relation' do @@ -208,30 +213,20 @@ RSpec.describe Project, 'Routable', :with_clean_rails_cache do it_behaves_like 'routable resource with parent' do let_it_be(:record) { project } end + + it 'creates route with namespace referencing project namespace' do + expect(project.route).not_to be_nil + expect(project.route.namespace).to eq(project.project_namespace) + end end RSpec.describe Namespaces::ProjectNamespace, 'Routable', :with_clean_rails_cache do let_it_be(:group) { create(:group) } - let_it_be(:project_namespace) do - # For now we create only project namespace w/o project, otherwise same path - # would be used for project and project namespace. - # This can be removed when route is created automatically for project namespaces. - # https://gitlab.com/gitlab-org/gitlab/-/issues/346448 - create(:project_namespace, project: nil, parent: group, - visibility_level: Gitlab::VisibilityLevel::PUBLIC, - path: 'foo', name: 'foo').tap do |project_namespace| - Route.create!(source: project_namespace, path: project_namespace.full_path, - name: project_namespace.full_name) - end - end - - # we have couple of places where we use generic Namespace, in that case - # we don't want to include ProjectNamespace routes yet - it 'ignores project namespace when searching for generic namespace' do - redirect_route = create(:redirect_route, source: project_namespace) - expect(Namespace.find_by_full_path(project_namespace.full_path)).to be_nil - expect(Namespace.find_by_full_path(redirect_route.path, follow_redirects: true)).to be_nil + it 'skips route creation for the resource' do + expect do + described_class.create!(project: nil, parent: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC, path: 'foo', name: 'foo') + end.not_to change { Route.count } end end diff --git a/spec/models/concerns/triggerable_hooks_spec.rb b/spec/models/concerns/triggerable_hooks_spec.rb index 10a6c1aa821..90c88c888ff 100644 --- a/spec/models/concerns/triggerable_hooks_spec.rb +++ b/spec/models/concerns/triggerable_hooks_spec.rb @@ -46,7 +46,7 @@ RSpec.describe TriggerableHooks do describe '.select_active' do it 'returns hooks that match the active filter' do TestableHook.create!(url: 'http://example1.com', push_events: true) - TestableHook.create!(url: 'http://example2.com', push_events: true) + TestableHook.create!(url: 'http://example.org', push_events: true) filter1 = double(:filter1) filter2 = double(:filter2) allow(ActiveHookFilter).to receive(:new).twice.and_return(filter1, filter2) diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index 51fdbfebd3a..8f7c13d7ae6 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -25,12 +25,20 @@ RSpec.describe ContainerRepository do headers: { 'Content-Type' => 'application/json' }) end + it_behaves_like 'having unique enum values' + describe 'associations' do it 'belongs to the project' do expect(repository).to belong_to(:project) end end + describe 'validations' do + it { is_expected.to validate_presence_of(:migration_retries_count) } + it { is_expected.to validate_numericality_of(:migration_retries_count).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_presence_of(:migration_state) } + end + describe '#tag' do it 'has a test tag' do expect(repository.tag('test')).not_to be_nil diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb index 7e26d324ac2..1225f9d089b 100644 --- a/spec/models/customer_relations/contact_spec.rb +++ b/spec/models/customer_relations/contact_spec.rb @@ -26,6 +26,38 @@ RSpec.describe CustomerRelations::Contact, type: :model do it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email end + describe '#unique_email_for_group_hierarchy' do + let_it_be(:parent) { create(:group) } + let_it_be(:group) { create(:group, parent: parent) } + let_it_be(:subgroup) { create(:group, parent: group) } + + let_it_be(:existing_contact) { create(:contact, group: group) } + + context 'with unique email for group hierarchy' do + subject { build(:contact, group: group) } + + it { is_expected.to be_valid } + end + + context 'with duplicate email in group' do + subject { build(:contact, email: existing_contact.email, group: group) } + + it { is_expected.to be_invalid } + end + + context 'with duplicate email in parent group' do + subject { build(:contact, email: existing_contact.email, group: subgroup) } + + it { is_expected.to be_invalid } + end + + context 'with duplicate email in subgroup' do + subject { build(:contact, email: existing_contact.email, group: parent) } + + it { is_expected.to be_invalid } + end + end + describe '#before_validation' do it 'strips leading and trailing whitespace' do contact = described_class.new(first_name: ' First ', last_name: ' Last ', phone: ' 123456 ') @@ -43,20 +75,27 @@ RSpec.describe CustomerRelations::Contact, type: :model do let_it_be(:other_contacts) { create_list(:contact, 2) } it 'returns ids of contacts from group' do - contact_ids = described_class.find_ids_by_emails(group.id, group_contacts.pluck(:email)) + contact_ids = described_class.find_ids_by_emails(group, group_contacts.pluck(:email)) + + expect(contact_ids).to match_array(group_contacts.pluck(:id)) + end + + it 'returns ids of contacts from parent group' do + subgroup = create(:group, parent: group) + contact_ids = described_class.find_ids_by_emails(subgroup, group_contacts.pluck(:email)) expect(contact_ids).to match_array(group_contacts.pluck(:id)) end it 'does not return ids of contacts from other groups' do - contact_ids = described_class.find_ids_by_emails(group.id, other_contacts.pluck(:email)) + contact_ids = described_class.find_ids_by_emails(group, other_contacts.pluck(:email)) expect(contact_ids).to be_empty end it 'raises ArgumentError when called with too many emails' do too_many_emails = described_class::MAX_PLUCK + 1 - expect { described_class.find_ids_by_emails(group.id, Array(0..too_many_emails)) }.to raise_error(ArgumentError) + expect { described_class.find_ids_by_emails(group, Array(0..too_many_emails)) }.to raise_error(ArgumentError) end end end diff --git a/spec/models/customer_relations/issue_contact_spec.rb b/spec/models/customer_relations/issue_contact_spec.rb index 474455a9884..c6373fddbfb 100644 --- a/spec/models/customer_relations/issue_contact_spec.rb +++ b/spec/models/customer_relations/issue_contact_spec.rb @@ -5,7 +5,8 @@ require 'spec_helper' RSpec.describe CustomerRelations::IssueContact do let_it_be(:issue_contact, reload: true) { create(:issue_customer_relations_contact) } let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, group: group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:project) { create(:project, group: subgroup) } let_it_be(:issue) { create(:issue, project: project) } subject { issue_contact } @@ -33,17 +34,29 @@ RSpec.describe CustomerRelations::IssueContact do end it 'builds using the same group', :aggregate_failures do - expect(for_issue.contact.group).to eq(group) + expect(for_issue.contact.group).to eq(subgroup) expect(for_contact.issue.project.group).to eq(group) end end describe 'validation' do - let(:built) { build(:issue_customer_relations_contact, issue: create(:issue), contact: create(:contact)) } + it 'fails when the contact group does not belong to the issue group or ancestors' do + built = build(:issue_customer_relations_contact, issue: create(:issue), contact: create(:contact)) - it 'fails when the contact group does not match the issue group' do expect(built).not_to be_valid end + + it 'succeeds when the contact group is the same as the issue group' do + built = build(:issue_customer_relations_contact, issue: create(:issue, project: project), contact: create(:contact, group: subgroup)) + + expect(built).to be_valid + end + + it 'succeeds when the contact group is an ancestor of the issue group' do + built = build(:issue_customer_relations_contact, issue: create(:issue, project: project), contact: create(:contact, group: group)) + + expect(built).to be_valid + end end describe '#self.find_contact_ids_by_emails' do diff --git a/spec/models/dependency_proxy/blob_spec.rb b/spec/models/dependency_proxy/blob_spec.rb index 3c54d3126a8..10d06406ad7 100644 --- a/spec/models/dependency_proxy/blob_spec.rb +++ b/spec/models/dependency_proxy/blob_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe DependencyProxy::Blob, type: :model do it_behaves_like 'ttl_expirable' + it_behaves_like 'destructible', factory: :dependency_proxy_blob describe 'relationships' do it { is_expected.to belong_to(:group) } diff --git a/spec/models/dependency_proxy/manifest_spec.rb b/spec/models/dependency_proxy/manifest_spec.rb index 59415096989..ab7881b1d39 100644 --- a/spec/models/dependency_proxy/manifest_spec.rb +++ b/spec/models/dependency_proxy/manifest_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe DependencyProxy::Manifest, type: :model do it_behaves_like 'ttl_expirable' + it_behaves_like 'destructible', factory: :dependency_proxy_manifest describe 'relationships' do it { is_expected.to belong_to(:group) } diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb index 59299a507e4..b76063bfa1a 100644 --- a/spec/models/email_spec.rb +++ b/spec/models/email_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Email do end describe 'validations' do - it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email do + it_behaves_like 'an object with email-formatted attributes', :email do subject { build(:email) } end @@ -71,4 +71,84 @@ RSpec.describe Email do end end end + + describe '#confirm' do + let(:expired_confirmation_sent_at) { Date.today - described_class.confirm_within - 7.days } + let(:extant_confirmation_sent_at) { Date.today } + + let(:email) do + create(:email, email: 'test@gitlab.com').tap do |email| + email.update!(confirmation_sent_at: confirmation_sent_at) + end + end + + shared_examples_for 'unconfirmed email' do + it 'returns unconfirmed' do + expect(email.confirmed?).to be_falsey + end + end + + context 'when the confirmation period has expired' do + let(:confirmation_sent_at) { expired_confirmation_sent_at } + + it_behaves_like 'unconfirmed email' + + it 'does not confirm the email' do + email.confirm + + expect(email.confirmed?).to be_falsey + end + end + + context 'when the confirmation period has not expired' do + let(:confirmation_sent_at) { extant_confirmation_sent_at } + + it_behaves_like 'unconfirmed email' + + it 'confirms the email' do + email.confirm + + expect(email.confirmed?).to be_truthy + end + end + end + + describe '#force_confirm' do + let(:expired_confirmation_sent_at) { Date.today - described_class.confirm_within - 7.days } + let(:extant_confirmation_sent_at) { Date.today } + + let(:email) do + create(:email, email: 'test@gitlab.com').tap do |email| + email.update!(confirmation_sent_at: confirmation_sent_at) + end + end + + shared_examples_for 'unconfirmed email' do + it 'returns unconfirmed' do + expect(email.confirmed?).to be_falsey + end + end + + shared_examples_for 'confirms the email on force_confirm' do + it 'confirms an email' do + email.force_confirm + + expect(email.reload.confirmed?).to be_truthy + end + end + + context 'when the confirmation period has expired' do + let(:confirmation_sent_at) { expired_confirmation_sent_at } + + it_behaves_like 'unconfirmed email' + it_behaves_like 'confirms the email on force_confirm' + end + + context 'when the confirmation period has not expired' do + let(:confirmation_sent_at) { extant_confirmation_sent_at } + + it_behaves_like 'unconfirmed email' + it_behaves_like 'confirms the email on force_confirm' + end + end end diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb index ea5d2b27028..de6ce3ba053 100644 --- a/spec/models/experiment_spec.rb +++ b/spec/models/experiment_spec.rb @@ -235,6 +235,54 @@ RSpec.describe Experiment do end end + describe '#record_conversion_event_for_subject' do + let_it_be(:user) { create(:user) } + let_it_be(:experiment) { create(:experiment) } + let_it_be(:context) { { a: 42 } } + + subject(:record_conversion) { experiment.record_conversion_event_for_subject(user, context) } + + context 'when no existing experiment_subject record exists for the given user' do + it 'does not update or create an experiment_subject record' do + expect { record_conversion }.not_to change { ExperimentSubject.all.to_a } + end + end + + context 'when an existing experiment_subject exists for the given user' do + context 'but it has already been converted' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user, converted_at: 2.days.ago) } + + it 'does not update the converted_at value' do + expect { record_conversion }.not_to change { experiment_subject.converted_at } + end + end + + context 'and it has not yet been converted' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) } + + it 'updates the converted_at value' do + expect { record_conversion }.to change { experiment_subject.reload.converted_at } + end + end + + context 'with no existing context' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user) } + + it 'updates the context' do + expect { record_conversion }.to change { experiment_subject.reload.context }.to('a' => 42) + end + end + + context 'with an existing context' do + let(:experiment_subject) { create(:experiment_subject, experiment: experiment, user: user, converted_at: 2.days.ago, context: { b: 1 } ) } + + it 'merges the context' do + expect { record_conversion }.to change { experiment_subject.reload.context }.to('a' => 42, 'b' => 1) + end + end + end + end + describe '#record_subject_and_variant!' do let_it_be(:subject_to_record) { create(:group) } let_it_be(:variant) { :control } diff --git a/spec/models/group/crm_settings_spec.rb b/spec/models/group/crm_settings_spec.rb new file mode 100644 index 00000000000..35fcdca6389 --- /dev/null +++ b/spec/models/group/crm_settings_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Group::CrmSettings do + describe 'associations' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + subject { build(:crm_settings) } + + it { is_expected.to validate_presence_of(:group) } + end +end diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb index 03cc9d7e64c..034a5c1dfc6 100644 --- a/spec/models/group_group_link_spec.rb +++ b/spec/models/group_group_link_spec.rb @@ -29,32 +29,6 @@ RSpec.describe GroupGroupLink do ]) end end - - describe '.public_or_visible_to_user' do - let!(:user_with_access) { create :user } - let!(:user_without_access) { create :user } - let!(:shared_with_group) { create :group, :private } - let!(:shared_group) { create :group } - let!(:private_group_group_link) { create(:group_group_link, shared_group: shared_group, shared_with_group: shared_with_group) } - - before do - shared_group.add_owner(user_with_access) - shared_group.add_owner(user_without_access) - shared_with_group.add_developer(user_with_access) - end - - context 'when user can access shared group' do - it 'returns the private group' do - expect(described_class.public_or_visible_to_user(shared_group, user_with_access)).to include(private_group_group_link) - end - end - - context 'when user does not have access to shared group' do - it 'does not return private group' do - expect(described_class.public_or_visible_to_user(shared_group, user_without_access)).not_to include(private_group_group_link) - end - end - end end describe 'validation' do diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index fed4ee3f3a4..05ee2166245 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Group do include ReloadHelpers + include StubGitlabCalls let!(:group) { create(:group) } @@ -39,6 +40,7 @@ RSpec.describe Group do it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') } it { is_expected.to have_many(:contacts).class_name('CustomerRelations::Contact') } it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') } + it { is_expected.to have_one(:crm_settings) } describe '#members & #requesters' do let(:requester) { create(:user) } @@ -63,6 +65,7 @@ RSpec.describe Group do describe 'validations' do it { is_expected.to validate_presence_of :name } + it { is_expected.not_to allow_value('colon:in:path').for(:path) } # This is to validate that a specially crafted name cannot bypass a pattern match. See !72555 it { is_expected.to allow_value('group test_4').for(:name) } it { is_expected.not_to allow_value('test/../foo').for(:name) } it { is_expected.not_to allow_value('<script>alert("Attack!")</script>').for(:name) } @@ -502,6 +505,10 @@ RSpec.describe Group do it { expect(group.descendants.to_sql).not_to include 'traversal_ids @>' } end + describe '#self_and_hierarchy' do + it { expect(group.self_and_hierarchy.to_sql).not_to include 'traversal_ids @>' } + end + describe '#ancestors' do it { expect(group.ancestors.to_sql).not_to include 'traversal_ids <@' } end @@ -526,6 +533,10 @@ RSpec.describe Group do it { expect(group.descendants.to_sql).to include 'traversal_ids @>' } end + describe '#self_and_hierarchy' do + it { expect(group.self_and_hierarchy.to_sql).to include 'traversal_ids @>' } + end + describe '#ancestors' do it { expect(group.ancestors.to_sql).to include "\"namespaces\".\"id\" = #{group.parent_id}" } @@ -670,6 +681,26 @@ RSpec.describe Group do expect(result).to match_array([internal_group]) end end + + describe 'by_ids_or_paths' do + let(:group_path) { 'group_path' } + let!(:group) { create(:group, path: group_path) } + let(:group_id) { group.id } + + it 'returns matching records based on paths' do + expect(described_class.by_ids_or_paths(nil, [group_path])).to match_array([group]) + end + + it 'returns matching records based on ids' do + expect(described_class.by_ids_or_paths([group_id], nil)).to match_array([group]) + end + + it 'returns matching records based on both paths and ids' do + new_group = create(:group) + + expect(described_class.by_ids_or_paths([new_group.id], [group_path])).to match_array([group, new_group]) + end + end end describe '#to_reference' do @@ -2056,6 +2087,23 @@ RSpec.describe Group do end end + describe '#bots' do + subject { group.bots } + + let_it_be(:group) { create(:group) } + let_it_be(:project_bot) { create(:user, :project_bot) } + let_it_be(:user) { create(:user) } + + before_all do + [project_bot, user].each do |member| + group.add_maintainer(member) + end + end + + it { is_expected.to contain_exactly(project_bot) } + it { is_expected.not_to include(user) } + end + describe '#related_group_ids' do let(:nested_group) { create(:group, parent: group) } let(:shared_with_group) { create(:group, parent: group) } @@ -2492,7 +2540,7 @@ RSpec.describe Group do end end - describe '#default_owner' do + describe '#first_owner' do let(:group) { build(:group) } context 'the group has owners' do @@ -2502,7 +2550,7 @@ RSpec.describe Group do end it 'is the first owner' do - expect(group.default_owner) + expect(group.first_owner) .to eq(group.owners.first) .and be_a(User) end @@ -2517,8 +2565,8 @@ RSpec.describe Group do end it 'is the first owner of the parent' do - expect(group.default_owner) - .to eq(parent.default_owner) + expect(group.first_owner) + .to eq(parent.first_owner) .and be_a(User) end end @@ -2529,7 +2577,7 @@ RSpec.describe Group do end it 'is the group.owner' do - expect(group.default_owner) + expect(group.first_owner) .to eq(group.owner) .and be_a(User) end @@ -2775,4 +2823,330 @@ RSpec.describe Group do end end end + + describe '#dependency_proxy_setting' do + subject(:setting) { group.dependency_proxy_setting } + + it 'builds a new policy if one does not exist', :aggregate_failures do + expect(setting.enabled).to eq(true) + expect(setting).not_to be_persisted + end + + context 'with existing policy' do + before do + group.dependency_proxy_setting.update!(enabled: false) + end + + it 'returns the policy if it already exists', :aggregate_failures do + expect(setting.enabled).to eq(false) + expect(setting).to be_persisted + end + end + end + + describe '#crm_enabled?' do + it 'returns false where no crm_settings exist' do + expect(group.crm_enabled?).to be_falsey + end + + it 'returns false where crm_settings.state is disabled' do + create(:crm_settings, enabled: false, group: group) + + expect(group.crm_enabled?).to be_falsey + end + + it 'returns true where crm_settings.state is enabled' do + create(:crm_settings, enabled: true, group: group) + + expect(group.crm_enabled?).to be_truthy + end + end + describe '.get_ids_by_ids_or_paths' do + let(:group_path) { 'group_path' } + let!(:group) { create(:group, path: group_path) } + let(:group_id) { group.id } + + it 'returns ids matching records based on paths' do + expect(described_class.get_ids_by_ids_or_paths(nil, [group_path])).to match_array([group_id]) + end + + it 'returns ids matching records based on ids' do + expect(described_class.get_ids_by_ids_or_paths([group_id], nil)).to match_array([group_id]) + end + + it 'returns ids matching records based on both paths and ids' do + new_group_id = create(:group).id + + expect(described_class.get_ids_by_ids_or_paths([new_group_id], [group_path])).to match_array([group_id, new_group_id]) + end + end + + describe '#shared_with_group_links_visible_to_user' do + let_it_be(:admin) { create :admin } + let_it_be(:normal_user) { create :user } + let_it_be(:user_with_access) { create :user } + let_it_be(:user_with_parent_access) { create :user } + let_it_be(:user_without_access) { create :user } + let_it_be(:shared_group) { create :group } + let_it_be(:parent_group) { create :group, :private } + let_it_be(:shared_with_private_group) { create :group, :private, parent: parent_group } + let_it_be(:shared_with_internal_group) { create :group, :internal } + let_it_be(:shared_with_public_group) { create :group, :public } + let_it_be(:private_group_group_link) { create(:group_group_link, shared_group: shared_group, shared_with_group: shared_with_private_group) } + let_it_be(:internal_group_group_link) { create(:group_group_link, shared_group: shared_group, shared_with_group: shared_with_internal_group) } + let_it_be(:public_group_group_link) { create(:group_group_link, shared_group: shared_group, shared_with_group: shared_with_public_group) } + + before do + shared_with_private_group.add_developer(user_with_access) + parent_group.add_developer(user_with_parent_access) + end + + context 'when user is admin', :enable_admin_mode do + it 'returns all existing shared group links' do + expect(shared_group.shared_with_group_links_visible_to_user(admin)).to contain_exactly(private_group_group_link, internal_group_group_link, public_group_group_link) + end + end + + context 'when user is nil' do + it 'returns only link of public shared group' do + expect(shared_group.shared_with_group_links_visible_to_user(nil)).to contain_exactly(public_group_group_link) + end + end + + context 'when user has no access to private shared group' do + it 'returns links of internal and public shared groups' do + expect(shared_group.shared_with_group_links_visible_to_user(normal_user)).to contain_exactly(internal_group_group_link, public_group_group_link) + end + end + + context 'when user is member of private shared group' do + it 'returns links of private, internal and public shared groups' do + expect(shared_group.shared_with_group_links_visible_to_user(user_with_access)).to contain_exactly(private_group_group_link, internal_group_group_link, public_group_group_link) + end + end + + context 'when user is inherited member of private shared group' do + it 'returns links of private, internal and public shared groups' do + expect(shared_group.shared_with_group_links_visible_to_user(user_with_parent_access)).to contain_exactly(private_group_group_link, internal_group_group_link, public_group_group_link) + end + end + end + + describe '#enforced_runner_token_expiration_interval and #effective_runner_token_expiration_interval' do + shared_examples 'no enforced expiration interval' do + it { expect(subject.enforced_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'enforced expiration interval' do |enforced_interval:| + it { expect(subject.enforced_runner_token_expiration_interval).to eq(enforced_interval) } + end + + shared_examples 'no effective expiration interval' do + it { expect(subject.effective_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'effective expiration interval' do |effective_interval:| + it { expect(subject.effective_runner_token_expiration_interval).to eq(effective_interval) } + end + + context 'when there is no interval in group settings' do + let_it_be(:group) { create(:group) } + + subject { group } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a group interval' do + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 3.days.to_i) } + + subject { create(:group, namespace_settings: group_settings) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + # runner_token_expiration_interval should not affect the expiration interval, only + # group_runner_token_expiration_interval should. + context 'when there is a site-wide enforced shared interval' do + before do + stub_application_setting(runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:group) { create(:group) } + + subject { group } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a site-wide enforced group interval' do + before do + stub_application_setting(group_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:group) { create(:group) } + + subject { group } + + it_behaves_like 'enforced expiration interval', enforced_interval: 5.days + it_behaves_like 'effective expiration interval', effective_interval: 5.days + end + + # project_runner_token_expiration_interval should not affect the expiration interval, only + # group_runner_token_expiration_interval should. + context 'when there is a site-wide enforced project interval' do + before do + stub_application_setting(project_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:group) { create(:group) } + + subject { group } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + # runner_token_expiration_interval should not affect the expiration interval, only + # subgroup_runner_token_expiration_interval should. + context 'when there is a grandparent group enforced group interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group) } + let_it_be(:subgroup) { create(:group, parent: parent_group) } + + subject { subgroup } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a grandparent group enforced subgroup interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group) } + let_it_be(:subgroup) { create(:group, parent: parent_group) } + + subject { subgroup } + + it_behaves_like 'enforced expiration interval', enforced_interval: 4.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + # project_runner_token_expiration_interval should not affect the expiration interval, only + # subgroup_runner_token_expiration_interval should. + context 'when there is a grandparent group enforced project interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group) } + let_it_be(:subgroup) { create(:group, parent: parent_group) } + + subject { subgroup } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a parent group enforced interval overridden by group interval' do + let_it_be(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 5.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let_it_be(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:subgroup_with_settings) { create(:group, parent: parent_group, namespace_settings: group_settings) } + + subject { subgroup_with_settings } + + it_behaves_like 'enforced expiration interval', enforced_interval: 5.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + + it 'has human-readable expiration intervals' do + expect(subject.enforced_runner_token_expiration_interval_human_readable).to eq('5d') + expect(subject.effective_runner_token_expiration_interval_human_readable).to eq('4d') + end + end + + context 'when site-wide enforced interval overrides group interval' do + before do + stub_application_setting(group_runner_token_expiration_interval: 3.days.to_i) + end + + let_it_be(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group_with_settings) { create(:group, namespace_settings: group_settings) } + + subject { group_with_settings } + + it_behaves_like 'enforced expiration interval', enforced_interval: 3.days + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + context 'when group interval overrides site-wide enforced interval' do + before do + stub_application_setting(group_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group_with_settings) { create(:group, namespace_settings: group_settings) } + + subject { group_with_settings } + + it_behaves_like 'enforced expiration interval', enforced_interval: 5.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + context 'when site-wide enforced interval overrides parent group enforced interval' do + before do + stub_application_setting(group_runner_token_expiration_interval: 3.days.to_i) + end + + let_it_be(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let_it_be(:subgroup) { create(:group, parent: parent_group) } + + subject { subgroup } + + it_behaves_like 'enforced expiration interval', enforced_interval: 3.days + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + context 'when parent group enforced interval overrides site-wide enforced interval' do + before do + stub_application_setting(group_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let_it_be(:subgroup) { create(:group, parent: parent_group) } + + subject { subgroup } + + it_behaves_like 'enforced expiration interval', enforced_interval: 4.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + # Unrelated groups should not affect the expiration interval. + context 'when there is an enforced group interval in an unrelated group' do + let_it_be(:unrelated_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:unrelated_group) { create(:group, namespace_settings: unrelated_group_settings) } + let_it_be(:group) { create(:group) } + + subject { group } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + # Subgroups should not affect the parent group expiration interval. + context 'when there is an enforced group interval in a subgroup' do + let_it_be(:subgroup_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:subgroup) { create(:group, parent: group, namespace_settings: subgroup_settings) } + let_it_be(:group) { create(:group) } + + subject { group } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + end end diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb index f0ee9a613d8..ec2eca96755 100644 --- a/spec/models/hooks/project_hook_spec.rb +++ b/spec/models/hooks/project_hook_spec.rb @@ -40,6 +40,15 @@ RSpec.describe ProjectHook do end end + describe '#parent' do + it 'returns the associated project' do + project = build(:project) + hook = build(:project_hook, project: project) + + expect(hook.parent).to eq(project) + end + end + describe '#application_context' do let_it_be(:hook) { build(:project_hook) } diff --git a/spec/models/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb index 4ce2e729d89..85f433f5f81 100644 --- a/spec/models/hooks/service_hook_spec.rb +++ b/spec/models/hooks/service_hook_spec.rb @@ -31,6 +31,36 @@ RSpec.describe ServiceHook do end end + describe '#parent' do + let(:hook) { build(:service_hook, integration: integration) } + + context 'with a project-level integration' do + let(:project) { build(:project) } + let(:integration) { build(:integration, project: project) } + + it 'returns the associated project' do + expect(hook.parent).to eq(project) + end + end + + context 'with a group-level integration' do + let(:group) { build(:group) } + let(:integration) { build(:integration, :group, group: group) } + + it 'returns the associated group' do + expect(hook.parent).to eq(group) + end + end + + context 'with an instance-level integration' do + let(:integration) { build(:integration, :instance) } + + it 'returns nil' do + expect(hook.parent).to be_nil + end + end + end + describe '#application_context' do let(:hook) { build(:service_hook) } diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb index 17cb5da977a..89bfb742f5d 100644 --- a/spec/models/hooks/system_hook_spec.rb +++ b/spec/models/hooks/system_hook_spec.rb @@ -37,7 +37,7 @@ RSpec.describe SystemHook do let(:project) { create(:project, namespace: user.namespace) } let(:group) { create(:group) } let(:params) do - { name: 'John Doe', username: 'jduser', email: 'jg@example.com', password: 'mydummypass' } + { name: 'John Doe', username: 'jduser', email: 'jg@example.com', password: Gitlab::Password.test_default } end before do diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb index 698d74abf03..a47bc6a5b6d 100644 --- a/spec/models/instance_configuration_spec.rb +++ b/spec/models/instance_configuration_spec.rb @@ -205,7 +205,8 @@ RSpec.describe InstanceConfiguration do group_export_limit: 1018, group_download_export_limit: 1019, group_import_limit: 1020, - raw_blob_request_limit: 1021 + raw_blob_request_limit: 1021, + user_email_lookup_limit: 1022 ) end @@ -228,6 +229,7 @@ RSpec.describe InstanceConfiguration do expect(rate_limits[:group_export_download]).to eq({ enabled: true, requests_per_period: 1019, period_in_seconds: 60 }) expect(rate_limits[:group_import]).to eq({ enabled: true, requests_per_period: 1020, period_in_seconds: 60 }) expect(rate_limits[:raw_blob]).to eq({ enabled: true, requests_per_period: 1021, period_in_seconds: 60 }) + expect(rate_limits[:user_email_lookup]).to eq({ enabled: true, requests_per_period: 1022, period_in_seconds: 60 }) end end end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index de47fb3839a..7bc670302f1 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -33,28 +33,28 @@ RSpec.describe Integration do end with_them do - it 'validates the service' do - expect(build(:service, project_id: project_id, group_id: group_id, instance: instance).valid?).to eq(valid) + it 'validates the integration' do + expect(build(:integration, project_id: project_id, group_id: group_id, instance: instance).valid?).to eq(valid) end end - context 'with existing services' do + context 'with existing integrations' do before_all do - create(:service, :instance) - create(:service, project: project) - create(:service, group: group, project: nil) + create(:integration, :instance) + create(:integration, project: project) + create(:integration, group: group, project: nil) end - it 'allows only one instance service per type' do - expect(build(:service, :instance)).to be_invalid + it 'allows only one instance integration per type' do + expect(build(:integration, :instance)).to be_invalid end - it 'allows only one project service per type' do - expect(build(:service, project: project)).to be_invalid + it 'allows only one project integration per type' do + expect(build(:integration, project: project)).to be_invalid end - it 'allows only one group service per type' do - expect(build(:service, group: group, project: nil)).to be_invalid + it 'allows only one group integration per type' do + expect(build(:integration, group: group, project: nil)).to be_invalid end end end @@ -79,93 +79,85 @@ RSpec.describe Integration do end describe '.by_type' do - let!(:service1) { create(:jira_integration) } - let!(:service2) { create(:jira_integration) } - let!(:service3) { create(:redmine_integration) } + let!(:integration1) { create(:jira_integration) } + let!(:integration2) { create(:jira_integration) } + let!(:integration3) { create(:redmine_integration) } subject { described_class.by_type(type) } context 'when type is "JiraService"' do let(:type) { 'JiraService' } - it { is_expected.to match_array([service1, service2]) } + it { is_expected.to match_array([integration1, integration2]) } end context 'when type is "RedmineService"' do let(:type) { 'RedmineService' } - it { is_expected.to match_array([service3]) } + it { is_expected.to match_array([integration3]) } end end describe '.for_group' do - let!(:service1) { create(:jira_integration, project_id: nil, group_id: group.id) } - let!(:service2) { create(:jira_integration) } + let!(:integration1) { create(:jira_integration, project_id: nil, group_id: group.id) } + let!(:integration2) { create(:jira_integration) } - it 'returns the right group service' do - expect(described_class.for_group(group)).to match_array([service1]) + it 'returns the right group integration' do + expect(described_class.for_group(group)).to match_array([integration1]) end end - describe '.confidential_note_hooks' do - it 'includes services where confidential_note_events is true' do - create(:service, active: true, confidential_note_events: true) + shared_examples 'hook scope' do |hook_type| + describe ".#{hook_type}_hooks" do + it "includes services where #{hook_type}_events is true" do + create(:integration, active: true, "#{hook_type}_events": true) - expect(described_class.confidential_note_hooks.count).to eq 1 - end + expect(described_class.send("#{hook_type}_hooks").count).to eq 1 + end - it 'excludes services where confidential_note_events is false' do - create(:service, active: true, confidential_note_events: false) + it "excludes services where #{hook_type}_events is false" do + create(:integration, active: true, "#{hook_type}_events": false) - expect(described_class.confidential_note_hooks.count).to eq 0 + expect(described_class.send("#{hook_type}_hooks").count).to eq 0 + end end end - describe '.alert_hooks' do - it 'includes services where alert_events is true' do - create(:service, active: true, alert_events: true) - - expect(described_class.alert_hooks.count).to eq 1 - end - - it 'excludes services where alert_events is false' do - create(:service, active: true, alert_events: false) - - expect(described_class.alert_hooks.count).to eq 0 - end - end + include_examples 'hook scope', 'confidential_note' + include_examples 'hook scope', 'alert' + include_examples 'hook scope', 'archive_trace' end describe '#operating?' do - it 'is false when the service is not active' do - expect(build(:service).operating?).to eq(false) + it 'is false when the integration is not active' do + expect(build(:integration).operating?).to eq(false) end - it 'is false when the service is not persisted' do - expect(build(:service, active: true).operating?).to eq(false) + it 'is false when the integration is not persisted' do + expect(build(:integration, active: true).operating?).to eq(false) end - it 'is true when the service is active and persisted' do - expect(create(:service, active: true).operating?).to eq(true) + it 'is true when the integration is active and persisted' do + expect(create(:integration, active: true).operating?).to eq(true) end end describe '#testable?' do context 'when integration is project-level' do - subject { build(:service, project: project) } + subject { build(:integration, project: project) } it { is_expected.to be_testable } end context 'when integration is not project-level' do - subject { build(:service, project: nil) } + subject { build(:integration, project: nil) } it { is_expected.not_to be_testable } end end describe '#test' do - let(:integration) { build(:service, project: project) } + let(:integration) { build(:integration, project: project) } let(:data) { 'test' } it 'calls #execute' do @@ -186,32 +178,32 @@ RSpec.describe Integration do end describe '#project_level?' do - it 'is true when service has a project' do - expect(build(:service, project: project)).to be_project_level + it 'is true when integration has a project' do + expect(build(:integration, project: project)).to be_project_level end - it 'is false when service has no project' do - expect(build(:service, project: nil)).not_to be_project_level + it 'is false when integration has no project' do + expect(build(:integration, project: nil)).not_to be_project_level end end describe '#group_level?' do - it 'is true when service has a group' do - expect(build(:service, group: group)).to be_group_level + it 'is true when integration has a group' do + expect(build(:integration, group: group)).to be_group_level end - it 'is false when service has no group' do - expect(build(:service, group: nil)).not_to be_group_level + it 'is false when integration has no group' do + expect(build(:integration, group: nil)).not_to be_group_level end end describe '#instance_level?' do - it 'is true when service has instance-level integration' do - expect(build(:service, :instance)).to be_instance_level + it 'is true when integration has instance-level integration' do + expect(build(:integration, :instance)).to be_instance_level end - it 'is false when service does not have instance-level integration' do - expect(build(:service, instance: false)).not_to be_instance_level + it 'is false when integration does not have instance-level integration' do + expect(build(:integration, instance: false)).not_to be_instance_level end end @@ -231,19 +223,19 @@ RSpec.describe Integration do end describe '.find_or_initialize_all_non_project_specific' do - shared_examples 'service instances' do - it 'returns the available service instances' do + shared_examples 'integration instances' do + it 'returns the available integration instances' do expect(Integration.find_or_initialize_all_non_project_specific(Integration.for_instance).map(&:to_param)) .to match_array(Integration.available_integration_names(include_project_specific: false)) end - it 'does not create service instances' do + it 'does not create integration instances' do expect { Integration.find_or_initialize_all_non_project_specific(Integration.for_instance) } .not_to change(Integration, :count) end end - it_behaves_like 'service instances' + it_behaves_like 'integration instances' context 'with all existing instances' do before do @@ -252,15 +244,15 @@ RSpec.describe Integration do ) end - it_behaves_like 'service instances' + it_behaves_like 'integration instances' - context 'with a previous existing service (MockCiService) and a new service (Asana)' do + context 'with a previous existing integration (MockCiService) and a new integration (Asana)' do before do Integration.insert({ type: 'MockCiService', instance: true }) Integration.delete_by(type: 'AsanaService', instance: true) end - it_behaves_like 'service instances' + it_behaves_like 'integration instances' end end @@ -269,7 +261,7 @@ RSpec.describe Integration do create(:jira_integration, :instance) end - it_behaves_like 'service instances' + it_behaves_like 'integration instances' end end @@ -320,31 +312,31 @@ RSpec.describe Integration do } end - shared_examples 'service creation from an integration' do - it 'creates a correct service for a project integration' do - service = described_class.build_from_integration(integration, project_id: project.id) + shared_examples 'integration creation from an integration' do + it 'creates a correct integration for a project integration' do + new_integration = described_class.build_from_integration(integration, project_id: project.id) - expect(service).to be_active - expect(service.url).to eq(url) - expect(service.api_url).to eq(api_url) - expect(service.username).to eq(username) - expect(service.password).to eq(password) - expect(service.instance).to eq(false) - expect(service.project).to eq(project) - expect(service.group).to eq(nil) + expect(new_integration).to be_active + expect(new_integration.url).to eq(url) + expect(new_integration.api_url).to eq(api_url) + expect(new_integration.username).to eq(username) + expect(new_integration.password).to eq(password) + expect(new_integration.instance).to eq(false) + expect(new_integration.project).to eq(project) + expect(new_integration.group).to eq(nil) end - it 'creates a correct service for a group integration' do - service = described_class.build_from_integration(integration, group_id: group.id) - - expect(service).to be_active - expect(service.url).to eq(url) - expect(service.api_url).to eq(api_url) - expect(service.username).to eq(username) - expect(service.password).to eq(password) - expect(service.instance).to eq(false) - expect(service.project).to eq(nil) - expect(service.group).to eq(group) + it 'creates a correct integration for a group integration' do + new_integration = described_class.build_from_integration(integration, group_id: group.id) + + expect(new_integration).to be_active + expect(new_integration.url).to eq(url) + expect(new_integration.api_url).to eq(api_url) + expect(new_integration.username).to eq(username) + expect(new_integration.password).to eq(password) + expect(new_integration.instance).to eq(false) + expect(new_integration.project).to eq(nil) + expect(new_integration.group).to eq(group) end end @@ -355,7 +347,7 @@ RSpec.describe Integration do create(:jira_integration, :without_properties_callback, properties: properties.merge(additional: 'something')) end - it_behaves_like 'service creation from an integration' + it_behaves_like 'integration creation from an integration' end context 'when data are stored in separated fields' do @@ -363,7 +355,7 @@ RSpec.describe Integration do create(:jira_integration, data_params.merge(properties: {})) end - it_behaves_like 'service creation from an integration' + it_behaves_like 'integration creation from an integration' end context 'when data are stored in both properties and separated fields' do @@ -374,7 +366,7 @@ RSpec.describe Integration do end end - it_behaves_like 'service creation from an integration' + it_behaves_like 'integration creation from an integration' end end end @@ -565,17 +557,17 @@ RSpec.describe Integration do end describe '.integration_name_to_model' do - it 'returns the model for the given service name' do + it 'returns the model for the given integration name' do expect(described_class.integration_name_to_model('asana')).to eq(Integrations::Asana) end - it 'raises an error if service name is invalid' do + it 'raises an error if integration name is invalid' do expect { described_class.integration_name_to_model('foo') }.to raise_exception(NameError, /uninitialized constant FooService/) end end describe "{property}_changed?" do - let(:service) do + let(:integration) do Integrations::Bamboo.create!( project: project, properties: { @@ -587,35 +579,35 @@ RSpec.describe Integration do end it "returns false when the property has not been assigned a new value" do - service.username = "key_changed" - expect(service.bamboo_url_changed?).to be_falsy + integration.username = "key_changed" + expect(integration.bamboo_url_changed?).to be_falsy end it "returns true when the property has been assigned a different value" do - service.bamboo_url = "http://example.com" - expect(service.bamboo_url_changed?).to be_truthy + integration.bamboo_url = "http://example.com" + expect(integration.bamboo_url_changed?).to be_truthy end it "returns true when the property has been assigned a different value twice" do - service.bamboo_url = "http://example.com" - service.bamboo_url = "http://example.com" - expect(service.bamboo_url_changed?).to be_truthy + integration.bamboo_url = "http://example.com" + integration.bamboo_url = "http://example.com" + expect(integration.bamboo_url_changed?).to be_truthy end it "returns false when the property has been re-assigned the same value" do - service.bamboo_url = 'http://gitlab.com' - expect(service.bamboo_url_changed?).to be_falsy + integration.bamboo_url = 'http://gitlab.com' + expect(integration.bamboo_url_changed?).to be_falsy end it "returns false when the property has been assigned a new value then saved" do - service.bamboo_url = 'http://example.com' - service.save! - expect(service.bamboo_url_changed?).to be_falsy + integration.bamboo_url = 'http://example.com' + integration.save! + expect(integration.bamboo_url_changed?).to be_falsy end end describe "{property}_touched?" do - let(:service) do + let(:integration) do Integrations::Bamboo.create!( project: project, properties: { @@ -627,35 +619,35 @@ RSpec.describe Integration do end it "returns false when the property has not been assigned a new value" do - service.username = "key_changed" - expect(service.bamboo_url_touched?).to be_falsy + integration.username = "key_changed" + expect(integration.bamboo_url_touched?).to be_falsy end it "returns true when the property has been assigned a different value" do - service.bamboo_url = "http://example.com" - expect(service.bamboo_url_touched?).to be_truthy + integration.bamboo_url = "http://example.com" + expect(integration.bamboo_url_touched?).to be_truthy end it "returns true when the property has been assigned a different value twice" do - service.bamboo_url = "http://example.com" - service.bamboo_url = "http://example.com" - expect(service.bamboo_url_touched?).to be_truthy + integration.bamboo_url = "http://example.com" + integration.bamboo_url = "http://example.com" + expect(integration.bamboo_url_touched?).to be_truthy end it "returns true when the property has been re-assigned the same value" do - service.bamboo_url = 'http://gitlab.com' - expect(service.bamboo_url_touched?).to be_truthy + integration.bamboo_url = 'http://gitlab.com' + expect(integration.bamboo_url_touched?).to be_truthy end it "returns false when the property has been assigned a new value then saved" do - service.bamboo_url = 'http://example.com' - service.save! - expect(service.bamboo_url_changed?).to be_falsy + integration.bamboo_url = 'http://example.com' + integration.save! + expect(integration.bamboo_url_changed?).to be_falsy end end describe "{property}_was" do - let(:service) do + let(:integration) do Integrations::Bamboo.create!( project: project, properties: { @@ -667,35 +659,35 @@ RSpec.describe Integration do end it "returns nil when the property has not been assigned a new value" do - service.username = "key_changed" - expect(service.bamboo_url_was).to be_nil + integration.username = "key_changed" + expect(integration.bamboo_url_was).to be_nil end it "returns the previous value when the property has been assigned a different value" do - service.bamboo_url = "http://example.com" - expect(service.bamboo_url_was).to eq('http://gitlab.com') + integration.bamboo_url = "http://example.com" + expect(integration.bamboo_url_was).to eq('http://gitlab.com') end it "returns initial value when the property has been re-assigned the same value" do - service.bamboo_url = 'http://gitlab.com' - expect(service.bamboo_url_was).to eq('http://gitlab.com') + integration.bamboo_url = 'http://gitlab.com' + expect(integration.bamboo_url_was).to eq('http://gitlab.com') end it "returns initial value when the property has been assigned multiple values" do - service.bamboo_url = "http://example.com" - service.bamboo_url = "http://example2.com" - expect(service.bamboo_url_was).to eq('http://gitlab.com') + integration.bamboo_url = "http://example.com" + integration.bamboo_url = "http://example.org" + expect(integration.bamboo_url_was).to eq('http://gitlab.com') end it "returns nil when the property has been assigned a new value then saved" do - service.bamboo_url = 'http://example.com' - service.save! - expect(service.bamboo_url_was).to be_nil + integration.bamboo_url = 'http://example.com' + integration.save! + expect(integration.bamboo_url_was).to be_nil end end - describe 'initialize service with no properties' do - let(:service) do + describe 'initialize integration with no properties' do + let(:integration) do Integrations::Bugzilla.create!( project: project, project_url: 'http://gitlab.example.com' @@ -703,16 +695,16 @@ RSpec.describe Integration do end it 'does not raise error' do - expect { service }.not_to raise_error + expect { integration }.not_to raise_error end it 'sets data correctly' do - expect(service.data_fields.project_url).to eq('http://gitlab.example.com') + expect(integration.data_fields.project_url).to eq('http://gitlab.example.com') end end describe '#api_field_names' do - let(:fake_service) do + let(:fake_integration) do Class.new(Integration) do def fields [ @@ -728,8 +720,8 @@ RSpec.describe Integration do end end - let(:service) do - fake_service.new(properties: [ + let(:integration) do + fake_integration.new(properties: [ { token: 'token-value' }, { api_token: 'api_token-value' }, { key: 'key-value' }, @@ -741,16 +733,16 @@ RSpec.describe Integration do end it 'filters out sensitive fields' do - expect(service.api_field_names).to eq(['safe_field']) + expect(integration.api_field_names).to eq(['safe_field']) end end context 'logging' do - let(:service) { build(:service, project: project) } + let(:integration) { build(:integration, project: project) } let(:test_message) { "test message" } let(:arguments) do { - service_class: service.class.name, + service_class: integration.class.name, project_path: project.full_path, project_id: project.id, message: test_message, @@ -761,20 +753,20 @@ RSpec.describe Integration do it 'logs info messages using json logger' do expect(Gitlab::JsonLogger).to receive(:info).with(arguments) - service.log_info(test_message, additional_argument: 'some argument') + integration.log_info(test_message, additional_argument: 'some argument') end it 'logs error messages using json logger' do expect(Gitlab::JsonLogger).to receive(:error).with(arguments) - service.log_error(test_message, additional_argument: 'some argument') + integration.log_error(test_message, additional_argument: 'some argument') end context 'when project is nil' do let(:project) { nil } let(:arguments) do { - service_class: service.class.name, + service_class: integration.class.name, project_path: nil, project_id: nil, message: test_message, @@ -785,7 +777,7 @@ RSpec.describe Integration do it 'logs info messages using json logger' do expect(Gitlab::JsonLogger).to receive(:info).with(arguments) - service.log_info(test_message, additional_argument: 'some argument') + integration.log_info(test_message, additional_argument: 'some argument') end end end diff --git a/spec/models/integrations/asana_spec.rb b/spec/models/integrations/asana_spec.rb index f7e7eb1b0ae..b6602964182 100644 --- a/spec/models/integrations/asana_spec.rb +++ b/spec/models/integrations/asana_spec.rb @@ -14,27 +14,29 @@ RSpec.describe Integrations::Asana do end describe 'Execute' do - let(:user) { create(:user) } - let(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let(:gid) { "123456789ABCD" } + let(:asana_task) { double(::Asana::Resources::Task) } + let(:asana_integration) { described_class.new } - def create_data_for_commits(*messages) + let(:data) do { object_kind: 'push', ref: 'master', user_name: user.name, - commits: messages.map do |m| + commits: [ { - message: m, + message: message, url: 'https://gitlab.com/' } - end + ] } end before do - @asana = described_class.new - allow(@asana).to receive_messages( + allow(asana_integration).to receive_messages( project: project, project_id: project.id, api_key: 'verySecret', @@ -42,67 +44,79 @@ RSpec.describe Integrations::Asana do ) end - it 'calls Asana integration to create a story' do - data = create_data_for_commits("Message from commit. related to ##{gid}") - expected_message = "#{data[:user_name]} pushed to branch #{data[:ref]} of #{project.full_name} ( #{data[:commits][0][:url]} ): #{data[:commits][0][:message]}" + subject(:execute_integration) { asana_integration.execute(data) } + + context 'when creating a story' do + let(:message) { "Message from commit. related to ##{gid}" } + let(:expected_message) do + "#{user.name} pushed to branch master of #{project.full_name} ( https://gitlab.com/ ): #{message}" + end - d1 = double('Asana::Resources::Task') - expect(d1).to receive(:add_comment).with(text: expected_message) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, gid).once.and_return(d1) + it 'calls Asana integration to create a story' do + expect(asana_task).to receive(:add_comment).with(text: expected_message) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, gid).once.and_return(asana_task) - @asana.execute(data) + execute_integration + end end - it 'calls Asana integration to create a story and close a task' do - data = create_data_for_commits('fix #456789') - d1 = double('Asana::Resources::Task') - expect(d1).to receive(:add_comment) - expect(d1).to receive(:update).with(completed: true) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '456789').once.and_return(d1) + context 'when creating a story and closing a task' do + let(:message) { 'fix #456789' } - @asana.execute(data) + it 'calls Asana integration to create a story and close a task' do + expect(asana_task).to receive(:add_comment) + expect(asana_task).to receive(:update).with(completed: true) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '456789').once.and_return(asana_task) + + execute_integration + end end - it 'is able to close via url' do - data = create_data_for_commits('closes https://app.asana.com/19292/956299/42') - d1 = double('Asana::Resources::Task') - expect(d1).to receive(:add_comment) - expect(d1).to receive(:update).with(completed: true) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '42').once.and_return(d1) + context 'when closing via url' do + let(:message) { 'closes https://app.asana.com/19292/956299/42' } - @asana.execute(data) + it 'calls Asana integration to close via url' do + expect(asana_task).to receive(:add_comment) + expect(asana_task).to receive(:update).with(completed: true) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '42').once.and_return(asana_task) + + execute_integration + end end - it 'allows multiple matches per line' do - message = <<-EOF - minor bigfix, refactoring, fixed #123 and Closes #456 work on #789 - ref https://app.asana.com/19292/956299/42 and closing https://app.asana.com/19292/956299/12 - EOF - data = create_data_for_commits(message) - d1 = double('Asana::Resources::Task') - expect(d1).to receive(:add_comment) - expect(d1).to receive(:update).with(completed: true) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '123').once.and_return(d1) - - d2 = double('Asana::Resources::Task') - expect(d2).to receive(:add_comment) - expect(d2).to receive(:update).with(completed: true) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '456').once.and_return(d2) - - d3 = double('Asana::Resources::Task') - expect(d3).to receive(:add_comment) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '789').once.and_return(d3) - - d4 = double('Asana::Resources::Task') - expect(d4).to receive(:add_comment) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '42').once.and_return(d4) - - d5 = double('Asana::Resources::Task') - expect(d5).to receive(:add_comment) - expect(d5).to receive(:update).with(completed: true) - expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '12').once.and_return(d5) - - @asana.execute(data) + context 'with multiple matches per line' do + let(:message) do + <<-EOF + minor bigfix, refactoring, fixed #123 and Closes #456 work on #789 + ref https://app.asana.com/19292/956299/42 and closing https://app.asana.com/19292/956299/12 + EOF + end + + it 'allows multiple matches per line' do + expect(asana_task).to receive(:add_comment) + expect(asana_task).to receive(:update).with(completed: true) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '123').once.and_return(asana_task) + + asana_task_2 = double(Asana::Resources::Task) + expect(asana_task_2).to receive(:add_comment) + expect(asana_task_2).to receive(:update).with(completed: true) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '456').once.and_return(asana_task_2) + + asana_task_3 = double(Asana::Resources::Task) + expect(asana_task_3).to receive(:add_comment) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '789').once.and_return(asana_task_3) + + asana_task_4 = double(Asana::Resources::Task) + expect(asana_task_4).to receive(:add_comment) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '42').once.and_return(asana_task_4) + + asana_task_5 = double(Asana::Resources::Task) + expect(asana_task_5).to receive(:add_comment) + expect(asana_task_5).to receive(:update).with(completed: true) + expect(::Asana::Resources::Task).to receive(:find_by_id).with(anything, '12').once.and_return(asana_task_5) + + execute_integration + end end end end diff --git a/spec/models/integrations/datadog_spec.rb b/spec/models/integrations/datadog_spec.rb index 9c3ff7aa35b..9856c53a390 100644 --- a/spec/models/integrations/datadog_spec.rb +++ b/spec/models/integrations/datadog_spec.rb @@ -38,6 +38,11 @@ RSpec.describe Integrations::Datadog do let(:pipeline_data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } let(:build_data) { Gitlab::DataBuilder::Build.build(build) } + let(:archive_trace_data) do + create(:ci_job_artifact, :trace, job: build) + + Gitlab::DataBuilder::ArchiveTrace.build(build) + end it_behaves_like Integrations::HasWebHook do let(:integration) { instance } @@ -100,6 +105,13 @@ RSpec.describe Integrations::Datadog do end end + describe '#help' do + subject { instance.help } + + it { is_expected.to be_a(String) } + it { is_expected.not_to be_empty } + end + describe '#hook_url' do subject { instance.hook_url } @@ -161,13 +173,16 @@ RSpec.describe Integrations::Datadog do end before do + stub_feature_flags(datadog_integration_logs_collection: enable_logs_collection) stub_request(:post, expected_hook_url) saved_instance.execute(data) end + let(:enable_logs_collection) { true } + context 'with pipeline data' do let(:data) { pipeline_data } - let(:expected_headers) { { WebHookService::GITLAB_EVENT_HEADER => 'Pipeline Hook' } } + let(:expected_headers) { { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Pipeline Hook' } } let(:expected_body) { data.with_retried_builds.to_json } it { expect(a_request(:post, expected_hook_url).with(headers: expected_headers, body: expected_body)).to have_been_made } @@ -175,10 +190,24 @@ RSpec.describe Integrations::Datadog do context 'with job data' do let(:data) { build_data } - let(:expected_headers) { { WebHookService::GITLAB_EVENT_HEADER => 'Job Hook' } } + let(:expected_headers) { { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Job Hook' } } + let(:expected_body) { data.to_json } + + it { expect(a_request(:post, expected_hook_url).with(headers: expected_headers, body: expected_body)).to have_been_made } + end + + context 'with archive trace data' do + let(:data) { archive_trace_data } + let(:expected_headers) { { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Archive Trace Hook' } } let(:expected_body) { data.to_json } it { expect(a_request(:post, expected_hook_url).with(headers: expected_headers, body: expected_body)).to have_been_made } + + context 'but feature flag disabled' do + let(:enable_logs_collection) { false } + + it { expect(a_request(:post, expected_hook_url)).not_to have_been_made } + end end end end diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb index 9163a7ef845..e80fa6e3b70 100644 --- a/spec/models/integrations/jira_spec.rb +++ b/spec/models/integrations/jira_spec.rb @@ -937,18 +937,6 @@ RSpec.describe Integrations::Jira do end end - context 'with jira_use_first_ref_by_oid feature flag disabled' do - before do - stub_feature_flags(jira_use_first_ref_by_oid: false) - end - - it 'creates a comment and remote link on Jira' do - expect(subject).to eq(success_message) - expect(WebMock).to have_requested(:post, comment_url).with(body: comment_body).once - expect(WebMock).to have_requested(:post, remote_link_url).once - end - end - it 'tracks usage' do expect(Gitlab::UsageDataCounters::HLLRedisCounter) .to receive(:track_event) diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index 51b27151ba2..f0007e1203c 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -87,7 +87,7 @@ RSpec.describe InternalId do context 'when executed outside of transaction' do it 'increments counter with in_transaction: "false"' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases + allow(ApplicationRecord.connection).to receive(:transaction_open?) { false } expect(InternalId.internal_id_transactions_total).to receive(:increment) .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original @@ -146,7 +146,7 @@ RSpec.describe InternalId do let(:value) { 2 } it 'increments counter with in_transaction: "false"' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases + allow(ApplicationRecord.connection).to receive(:transaction_open?) { false } expect(InternalId.internal_id_transactions_total).to receive(:increment) .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original @@ -217,7 +217,7 @@ RSpec.describe InternalId do context 'when executed outside of transaction' do it 'increments counter with in_transaction: "false"' do - allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases + allow(ApplicationRecord.connection).to receive(:transaction_open?) { false } expect(InternalId.internal_id_transactions_total).to receive(:increment) .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 4cbfa7c7758..c105f6c3439 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Issue do it { is_expected.to belong_to(:iteration) } it { is_expected.to belong_to(:project) } it { is_expected.to have_one(:namespace).through(:project) } - it { is_expected.to belong_to(:work_item_type).class_name('WorkItem::Type') } + it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type') } it { is_expected.to belong_to(:moved_to).class_name('Issue') } it { is_expected.to have_one(:moved_from).class_name('Issue') } it { is_expected.to belong_to(:duplicated_to).class_name('Issue') } @@ -238,6 +238,17 @@ RSpec.describe Issue do end end + # TODO: Remove when NOT NULL constraint is added to the relationship + describe '#work_item_type' do + let(:issue) { create(:issue, :incident, project: reusable_project, work_item_type: nil) } + + it 'returns a default type if the legacy issue does not have a work item type associated yet' do + expect(issue.work_item_type_id).to be_nil + expect(issue.issue_type).to eq('incident') + expect(issue.work_item_type).to eq(WorkItems::Type.default_by_type(:incident)) + end + end + describe '#sort' do let(:project) { reusable_project } @@ -1317,28 +1328,10 @@ RSpec.describe Issue do let_it_be(:issue1) { create(:issue, project: project, relative_position: nil) } let_it_be(:issue2) { create(:issue, project: project, relative_position: nil) } - context 'when optimized_issue_neighbor_queries is enabled' do - before do - stub_feature_flags(optimized_issue_neighbor_queries: true) - end - - it_behaves_like "a class that supports relative positioning" do - let_it_be(:project) { reusable_project } - let(:factory) { :issue } - let(:default_params) { { project: project } } - end - end - - context 'when optimized_issue_neighbor_queries is disabled' do - before do - stub_feature_flags(optimized_issue_neighbor_queries: false) - end - - it_behaves_like "a class that supports relative positioning" do - let_it_be(:project) { reusable_project } - let(:factory) { :issue } - let(:default_params) { { project: project } } - end + it_behaves_like "a class that supports relative positioning" do + let_it_be(:project) { reusable_project } + let(:factory) { :issue } + let(:default_params) { { project: project } } end it 'is not blocked for repositioning by default' do @@ -1580,4 +1573,13 @@ RSpec.describe Issue do expect(participant.issue.email_participants_emails_downcase).to match([participant.email.downcase]) end end + + describe '#escalation_status' do + it 'returns the incident_management_issuable_escalation_status association' do + escalation_status = create(:incident_management_issuable_escalation_status) + issue = escalation_status.issue + + expect(issue.escalation_status).to eq(escalation_status) + end + end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index d41a1604211..19459561edf 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -21,6 +21,28 @@ RSpec.describe Key, :mailer do it { is_expected.to allow_value(attributes_for(:ecdsa_key_256)[:key]).for(:key) } it { is_expected.to allow_value(attributes_for(:ed25519_key_256)[:key]).for(:key) } it { is_expected.not_to allow_value('foo-bar').for(:key) } + + context 'key format' do + let(:key) { build(:key) } + + it 'does not allow the key that begins with an algorithm name that is unsupported' do + key.key = 'unsupported-ssh-rsa key' + + key.valid? + + expect(key.errors.of_kind?(:key, :invalid)).to eq(true) + end + + Gitlab::SSHPublicKey.supported_algorithms.each do |supported_algorithm| + it "allows the key that begins with supported algorithm name '#{supported_algorithm}'" do + key.key = "#{supported_algorithm} key" + + key.valid? + + expect(key.errors.of_kind?(:key, :invalid)).to eq(false) + end + end + end end describe "Methods" do diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 7ce32de6edc..1957c58ec81 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Member do describe 'Associations' do it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:member_namespace) } it { is_expected.to have_one(:member_task) } end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e1db1b3cf3e..4005a2ec6da 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1648,10 +1648,7 @@ RSpec.describe MergeRequest, factory_default: :keep do it 'uses template from target project' do request = build(:merge_request, title: 'Fix everything') - request.compare_commits = [ - double(safe_message: 'Commit message', gitaly_commit?: true, merge_commit?: false, description?: false) - ] - subject.target_project.merge_commit_template = '%{title}' + request.target_project.merge_commit_template = '%{title}' expect(request.default_merge_commit_message) .to eq('Fix everything') @@ -3495,84 +3492,6 @@ RSpec.describe MergeRequest, factory_default: :keep do end end - describe "#environments_for" do - let(:project) { create(:project, :repository) } - let(:user) { project.creator } - let(:merge_request) { create(:merge_request, source_project: project) } - let(:source_branch) { merge_request.source_branch } - let(:target_branch) { merge_request.target_branch } - let(:source_oid) { project.commit(source_branch).id } - let(:target_oid) { project.commit(target_branch).id } - - before do - merge_request.source_project.add_maintainer(user) - merge_request.target_project.add_maintainer(user) - end - - context 'with multiple environments' do - let(:environments) { create_list(:environment, 3, project: project) } - - before do - create(:deployment, :success, environment: environments.first, ref: source_branch, sha: source_oid) - create(:deployment, :success, environment: environments.second, ref: target_branch, sha: target_oid) - end - - it 'selects deployed environments' do - expect(merge_request.environments_for(user)).to contain_exactly(environments.first) - end - - it 'selects latest deployed environment' do - latest_environment = create(:environment, project: project) - create(:deployment, :success, environment: latest_environment, ref: source_branch, sha: source_oid) - - expect(merge_request.environments_for(user)).to eq([environments.first, latest_environment]) - expect(merge_request.environments_for(user, latest: true)).to contain_exactly(latest_environment) - end - end - - context 'with environments on source project' do - let(:source_project) { fork_project(project, nil, repository: true) } - - let(:merge_request) do - create(:merge_request, - source_project: source_project, source_branch: 'feature', - target_project: project) - end - - let(:source_environment) { create(:environment, project: source_project) } - - before do - create(:deployment, :success, environment: source_environment, ref: 'feature', sha: merge_request.diff_head_sha) - end - - it 'selects deployed environments', :sidekiq_might_not_need_inline do - expect(merge_request.environments_for(user)).to contain_exactly(source_environment) - end - - context 'with environments on target project' do - let(:target_environment) { create(:environment, project: project) } - - before do - create(:deployment, :success, environment: target_environment, tag: true, sha: merge_request.diff_head_sha) - end - - it 'selects deployed environments', :sidekiq_might_not_need_inline do - expect(merge_request.environments_for(user)).to contain_exactly(source_environment, target_environment) - end - end - end - - context 'without a diff_head_commit' do - before do - expect(merge_request).to receive(:diff_head_commit).and_return(nil) - end - - it 'returns an empty array' do - expect(merge_request.environments_for(user)).to be_empty - end - end - end - describe "#environments" do subject { merge_request.environments } diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb index 429727c2360..c9f8a1bcdc2 100644 --- a/spec/models/namespace_setting_spec.rb +++ b/spec/models/namespace_setting_spec.rb @@ -126,57 +126,4 @@ RSpec.describe NamespaceSetting, type: :model do end end end - - describe 'hooks related to group user cap update' do - let(:settings) { create(:namespace_settings, new_user_signups_cap: user_cap) } - let(:group) { create(:group, namespace_settings: settings) } - - before do - allow(group).to receive(:root?).and_return(true) - end - - context 'when updating a group with a user cap' do - let(:user_cap) { nil } - - it 'also sets share_with_group_lock and prevent_sharing_groups_outside_hierarchy to true' do - expect(group.new_user_signups_cap).to be_nil - expect(group.share_with_group_lock).to be_falsey - expect(settings.prevent_sharing_groups_outside_hierarchy).to be_falsey - - settings.update!(new_user_signups_cap: 10) - group.reload - - expect(group.new_user_signups_cap).to eq(10) - expect(group.share_with_group_lock).to be_truthy - expect(settings.reload.prevent_sharing_groups_outside_hierarchy).to be_truthy - end - - it 'has share_with_group_lock and prevent_sharing_groups_outside_hierarchy returning true for descendent groups' do - descendent = create(:group, parent: group) - desc_settings = descendent.namespace_settings - - expect(descendent.share_with_group_lock).to be_falsey - expect(desc_settings.prevent_sharing_groups_outside_hierarchy).to be_falsey - - settings.update!(new_user_signups_cap: 10) - - expect(descendent.reload.share_with_group_lock).to be_truthy - expect(desc_settings.reload.prevent_sharing_groups_outside_hierarchy).to be_truthy - end - end - - context 'when removing a user cap from namespace settings' do - let(:user_cap) { 10 } - - it 'leaves share_with_group_lock and prevent_sharing_groups_outside_hierarchy set to true to the related group' do - expect(group.share_with_group_lock).to be_truthy - expect(settings.prevent_sharing_groups_outside_hierarchy).to be_truthy - - settings.update!(new_user_signups_cap: nil) - - expect(group.reload.share_with_group_lock).to be_truthy - expect(settings.reload.prevent_sharing_groups_outside_hierarchy).to be_truthy - end - end - end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 54327fc70d9..5da0f7a134c 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -28,6 +28,8 @@ RSpec.describe Namespace do it { is_expected.to have_one :onboarding_progress } it { is_expected.to have_one :admin_note } it { is_expected.to have_many :pending_builds } + it { is_expected.to have_one :namespace_route } + it { is_expected.to have_many :namespace_members } describe '#children' do let_it_be(:group) { create(:group) } @@ -1263,6 +1265,32 @@ RSpec.describe Namespace do end end + describe '#use_traversal_ids_for_self_and_hierarchy?' do + let_it_be(:namespace, reload: true) { create(:namespace) } + + subject { namespace.use_traversal_ids_for_self_and_hierarchy? } + + it { is_expected.to eq true } + + it_behaves_like 'disabled feature flag when traversal_ids is blank' + + context 'when use_traversal_ids_for_self_and_hierarchy feature flag is false' do + before do + stub_feature_flags(use_traversal_ids_for_self_and_hierarchy: false) + end + + it { is_expected.to eq false } + end + + context 'when use_traversal_ids? feature flag is false' do + before do + stub_feature_flags(use_traversal_ids: false) + end + + it { is_expected.to eq false } + end + end + describe '#users_with_descendants' do let(:user_a) { create(:user) } let(:user_b) { create(:user) } diff --git a/spec/models/namespaces/project_namespace_spec.rb b/spec/models/namespaces/project_namespace_spec.rb index 4416c49f1bf..47cf866c143 100644 --- a/spec/models/namespaces/project_namespace_spec.rb +++ b/spec/models/namespaces/project_namespace_spec.rb @@ -17,11 +17,11 @@ RSpec.describe Namespaces::ProjectNamespace, type: :model do let_it_be(:project) { create(:project) } let_it_be(:project_namespace) { project.project_namespace } - it 'also deletes the associated project' do + it 'keeps the associated project' do project_namespace.delete expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(project.reload.project_namespace).to be_nil end end end diff --git a/spec/models/onboarding_progress_spec.rb b/spec/models/onboarding_progress_spec.rb index deac8d29196..80a39404d10 100644 --- a/spec/models/onboarding_progress_spec.rb +++ b/spec/models/onboarding_progress_spec.rb @@ -131,29 +131,86 @@ RSpec.describe OnboardingProgress do end describe '.register' do - subject(:register_action) { described_class.register(namespace, action) } + context 'for a single action' do + subject(:register_action) { described_class.register(namespace, action) } - context 'when the namespace was onboarded' do - before do - described_class.onboard(namespace) - end + context 'when the namespace was onboarded' do + before do + described_class.onboard(namespace) + end - it 'registers the action for the namespace' do - expect { register_action }.to change { described_class.completed?(namespace, action) }.from(false).to(true) - end + it 'registers the action for the namespace' do + expect { register_action }.to change { described_class.completed?(namespace, action) }.from(false).to(true) + end - context 'when the action does not exist' do - let(:action) { :foo } + it 'does not override timestamp', :aggregate_failures do + expect(described_class.find_by_namespace_id(namespace.id).subscription_created_at).to be_nil + register_action + expect(described_class.find_by_namespace_id(namespace.id).subscription_created_at).not_to be_nil + expect { described_class.register(namespace, action) }.not_to change { described_class.find_by_namespace_id(namespace.id).subscription_created_at } + end + + context 'when the action does not exist' do + let(:action) { :foo } + it 'does not register the action for the namespace' do + expect { register_action }.not_to change { described_class.completed?(namespace, action) }.from(nil) + end + end + end + + context 'when the namespace was not onboarded' do it 'does not register the action for the namespace' do - expect { register_action }.not_to change { described_class.completed?(namespace, action) }.from(nil) + expect { register_action }.not_to change { described_class.completed?(namespace, action) }.from(false) end end end - context 'when the namespace was not onboarded' do - it 'does not register the action for the namespace' do - expect { register_action }.not_to change { described_class.completed?(namespace, action) }.from(false) + context 'for multiple actions' do + let(:action1) { :security_scan_enabled } + let(:action2) { :secure_dependency_scanning_run } + let(:actions) { [action1, action2] } + + subject(:register_action) { described_class.register(namespace, actions) } + + context 'when the namespace was onboarded' do + before do + described_class.onboard(namespace) + end + + it 'registers the actions for the namespace' do + expect { register_action }.to change { + [described_class.completed?(namespace, action1), described_class.completed?(namespace, action2)] + }.from([false, false]).to([true, true]) + end + + it 'does not override timestamp', :aggregate_failures do + described_class.register(namespace, [action1]) + expect(described_class.find_by_namespace_id(namespace.id).security_scan_enabled_at).not_to be_nil + expect(described_class.find_by_namespace_id(namespace.id).secure_dependency_scanning_run_at).to be_nil + + expect { described_class.register(namespace, [action1, action2]) }.not_to change { + described_class.find_by_namespace_id(namespace.id).security_scan_enabled_at + } + expect(described_class.find_by_namespace_id(namespace.id).secure_dependency_scanning_run_at).not_to be_nil + end + + context 'when one of the actions does not exist' do + let(:action2) { :foo } + + it 'does not register any action for the namespace' do + expect { register_action }.not_to change { + [described_class.completed?(namespace, action1), described_class.completed?(namespace, action2)] + }.from([false, nil]) + end + end + end + + context 'when the namespace was not onboarded' do + it 'does not register the action for the namespace' do + expect { register_action }.not_to change { described_class.completed?(namespace, action1) }.from(false) + expect { described_class.register(namespace, action) }.not_to change { described_class.completed?(namespace, action2) }.from(false) + end end end end diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index 8617793f41d..a86caa074f1 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -10,6 +10,9 @@ RSpec.describe Packages::PackageFile, type: :model do let_it_be(:package_file3) { create(:package_file, :xml, file_name: 'formatted.zip') } let_it_be(:debian_package) { create(:debian_package, project: project) } + it_behaves_like 'having unique enum values' + it_behaves_like 'destructible', factory: :package_file + describe 'relationships' do it { is_expected.to belong_to(:package) } it { is_expected.to have_one(:conan_file_metadatum) } @@ -138,6 +141,24 @@ RSpec.describe Packages::PackageFile, type: :model do it 'returns the matching file only for Helm packages' do expect(described_class.for_helm_with_channel(project, channel)).to contain_exactly(helm_file2) end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:helm_package_file, :pending_destruction, package: helm_package2, channel: channel) } + + it 'does not return them' do + expect(described_class.for_helm_with_channel(project, channel)).to contain_exactly(helm_file2) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + expect(described_class.for_helm_with_channel(project, channel)).to contain_exactly(helm_file2, package_file_pending_destruction) + end + end + end end describe '.most_recent!' do @@ -154,15 +175,17 @@ RSpec.describe Packages::PackageFile, type: :model do let_it_be(:package_file3_2) { create(:package_file, :npm, package: package3) } let_it_be(:package_file3_3) { create(:package_file, :npm, package: package3) } + let_it_be(:package_file3_4) { create(:package_file, :npm, :pending_destruction, package: package3) } let_it_be(:package_file4_2) { create(:package_file, :npm, package: package2) } let_it_be(:package_file4_3) { create(:package_file, :npm, package: package2) } let_it_be(:package_file4_4) { create(:package_file, :npm, package: package2) } + let_it_be(:package_file4_4) { create(:package_file, :npm, :pending_destruction, package: package2) } - let(:most_recent_package_file1) { package1.package_files.recent.first } - let(:most_recent_package_file2) { package2.package_files.recent.first } - let(:most_recent_package_file3) { package3.package_files.recent.first } - let(:most_recent_package_file4) { package4.package_files.recent.first } + let(:most_recent_package_file1) { package1.installable_package_files.recent.first } + let(:most_recent_package_file2) { package2.installable_package_files.recent.first } + let(:most_recent_package_file3) { package3.installable_package_files.recent.first } + let(:most_recent_package_file4) { package4.installable_package_files.recent.first } subject { described_class.most_recent_for(packages) } @@ -202,6 +225,24 @@ RSpec.describe Packages::PackageFile, type: :model do it 'returns the most recent package for the selected channel' do expect(subject).to contain_exactly(helm_package_file2) end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:helm_package_file, :pending_destruction, package: helm_package, channel: 'alpha') } + + it 'does not return them' do + expect(subject).to contain_exactly(helm_package_file2) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + expect(subject).to contain_exactly(package_file_pending_destruction) + end + end + end end end @@ -314,4 +355,25 @@ RSpec.describe Packages::PackageFile, type: :model do end end end + + context 'status scopes' do + let_it_be(:package) { create(:package) } + let_it_be(:default_package_file) { create(:package_file, package: package) } + let_it_be(:pending_destruction_package_file) { create(:package_file, :pending_destruction, package: package) } + + describe '.installable' do + subject { package.installable_package_files } + + it 'does not include non-displayable packages', :aggregate_failures do + is_expected.to include(default_package_file) + is_expected.not_to include(pending_destruction_package_file) + end + end + + describe '.with_status' do + subject { described_class.with_status(:pending_destruction) } + + it { is_expected.to contain_exactly(pending_destruction_package_file) } + end + end end diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 44ba6e0e2fd..122340f7bec 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -413,9 +413,17 @@ RSpec.describe Packages::Package, type: :model do it_behaves_like 'validating version to be SemVer compliant for', :terraform_module_package context 'nuget package' do - it_behaves_like 'validating version to be SemVer compliant for', :nuget_package + subject { build_stubbed(:nuget_package) } + it { is_expected.to allow_value('1.2').for(:version) } + it { is_expected.to allow_value('1.2.3').for(:version) } it { is_expected.to allow_value('1.2.3.4').for(:version) } + it { is_expected.to allow_value('1.2.3-beta').for(:version) } + it { is_expected.to allow_value('1.2.3-alpha.3').for(:version) } + it { is_expected.not_to allow_value('1').for(:version) } + it { is_expected.not_to allow_value('1./2.3').for(:version) } + it { is_expected.not_to allow_value('../../../../../1.2.3').for(:version) } + it { is_expected.not_to allow_value('%2e%2e%2f1.2.3').for(:version) } end end @@ -839,6 +847,7 @@ RSpec.describe Packages::Package, type: :model do end context 'status scopes' do + let_it_be(:default_package) { create(:maven_package, :default) } let_it_be(:hidden_package) { create(:maven_package, :hidden) } let_it_be(:processing_package) { create(:maven_package, :processing) } let_it_be(:error_package) { create(:maven_package, :error) } @@ -856,11 +865,15 @@ RSpec.describe Packages::Package, type: :model do describe '.installable' do subject { described_class.installable } - it 'does not include non-displayable packages', :aggregate_failures do + it 'does not include non-installable packages', :aggregate_failures do is_expected.not_to include(error_package) - is_expected.not_to include(hidden_package) is_expected.not_to include(processing_package) end + + it 'includes installable packages', :aggregate_failures do + is_expected.to include(default_package) + is_expected.to include(hidden_package) + end end describe '.with_status' do diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 8a5b1e73194..0735bf25690 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -336,129 +336,6 @@ RSpec.describe PagesDomain do end end - describe '#update_daemon' do - let_it_be(:project) { create(:project).tap(&:mark_pages_as_deployed) } - - context 'when usage is serverless' do - it 'does not call the UpdatePagesConfigurationService' do - expect(PagesUpdateConfigurationWorker).not_to receive(:perform_async) - - create(:pages_domain, usage: :serverless) - end - end - - it 'runs when the domain is created' do - domain = build(:pages_domain) - - expect(domain).to receive(:update_daemon) - - domain.save! - end - - it 'runs when the domain is destroyed' do - domain = create(:pages_domain) - - expect(domain).to receive(:update_daemon) - - domain.destroy! - end - - it "schedules a PagesUpdateConfigurationWorker" do - expect(PagesUpdateConfigurationWorker).to receive(:perform_async).with(project.id) - - create(:pages_domain, project: project) - end - - context "when the pages aren't deployed" do - let_it_be(:project) { create(:project).tap(&:mark_pages_as_not_deployed) } - - it "does not schedule a PagesUpdateConfigurationWorker" do - expect(PagesUpdateConfigurationWorker).not_to receive(:perform_async).with(project.id) - - create(:pages_domain, project: project) - end - end - - context 'configuration updates when attributes change' do - let_it_be(:project1) { create(:project) } - let_it_be(:project2) { create(:project) } - let_it_be(:domain) { create(:pages_domain) } - - where(:attribute, :old_value, :new_value, :update_expected) do - now = Time.current - future = now + 1.day - - :project | nil | :project1 | true - :project | :project1 | :project1 | false - :project | :project1 | :project2 | true - :project | :project1 | nil | true - - # domain can't be set to nil - :domain | 'a.com' | 'a.com' | false - :domain | 'a.com' | 'b.com' | true - - # verification_code can't be set to nil - :verification_code | 'foo' | 'foo' | false - :verification_code | 'foo' | 'bar' | false - - :verified_at | nil | now | false - :verified_at | now | now | false - :verified_at | now | future | false - :verified_at | now | nil | false - - :enabled_until | nil | now | true - :enabled_until | now | now | false - :enabled_until | now | future | false - :enabled_until | now | nil | true - end - - with_them do - it 'runs if a relevant attribute has changed' do - a = old_value.is_a?(Symbol) ? send(old_value) : old_value - b = new_value.is_a?(Symbol) ? send(new_value) : new_value - - domain.update!(attribute => a) - - if update_expected - expect(domain).to receive(:update_daemon) - else - expect(domain).not_to receive(:update_daemon) - end - - domain.update!(attribute => b) - end - end - - context 'TLS configuration' do - let_it_be(:domain_without_tls) { create(:pages_domain, :without_certificate, :without_key) } - let_it_be(:domain) { create(:pages_domain) } - - let(:cert1) { domain.certificate } - let(:cert2) { cert1 + ' ' } - let(:key1) { domain.key } - let(:key2) { key1 + ' ' } - - it 'updates when added' do - expect(domain_without_tls).to receive(:update_daemon) - - domain_without_tls.update!(key: key1, certificate: cert1) - end - - it 'updates when changed' do - expect(domain).to receive(:update_daemon) - - domain.update!(key: key2, certificate: cert2) - end - - it 'updates when removed' do - expect(domain).to receive(:update_daemon) - - domain.update!(key: nil, certificate: nil) - end - end - end - end - describe '#user_provided_key' do subject { domain.user_provided_key } diff --git a/spec/models/preloaders/environments/deployment_preloader_spec.rb b/spec/models/preloaders/environments/deployment_preloader_spec.rb new file mode 100644 index 00000000000..c1812d45628 --- /dev/null +++ b/spec/models/preloaders/environments/deployment_preloader_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::Environments::DeploymentPreloader do + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project, :repository) } + + let_it_be(:pipeline) { create(:ci_pipeline, user: user, project: project, sha: project.commit.sha) } + let_it_be(:ci_build_a) { create(:ci_build, user: user, project: project, pipeline: pipeline) } + let_it_be(:ci_build_b) { create(:ci_build, user: user, project: project, pipeline: pipeline) } + let_it_be(:ci_build_c) { create(:ci_build, user: user, project: project, pipeline: pipeline) } + + let_it_be(:environment_a) { create(:environment, project: project, state: :available) } + let_it_be(:environment_b) { create(:environment, project: project, state: :available) } + + before do + create(:deployment, :success, project: project, environment: environment_a, deployable: ci_build_a) + create(:deployment, :success, project: project, environment: environment_a, deployable: ci_build_b) + create(:deployment, :success, project: project, environment: environment_b, deployable: ci_build_c) + end + + def preload_association(association_name) + described_class.new(project.environments) + .execute_with_union(association_name, deployment_associations) + end + + def deployment_associations + { + user: [], + deployable: { + pipeline: { + manual_actions: [] + } + } + } + end + + it 'does not trigger N+1 queries' do + control = ActiveRecord::QueryRecorder.new { preload_association(:last_deployment) } + + ci_build_d = create(:ci_build, user: user, project: project, pipeline: pipeline) + create(:deployment, :success, project: project, environment: environment_b, deployable: ci_build_d) + + expect { preload_association(:last_deployment) }.not_to exceed_query_limit(control) + end + + it 'batch loads the dependent associations' do + preload_association(:last_deployment) + + expect do + project.environments.first.last_deployment.deployable.pipeline.manual_actions + end.not_to exceed_query_limit(0) + end + + # Example query scoped with IN clause for `last_deployment` association preload: + # SELECT DISTINCT ON (environment_id) deployments.* FROM "deployments" WHERE "deployments"."status" IN (1, 2, 3, 4, 6) AND "deployments"."environment_id" IN (35, 34, 33) ORDER BY environment_id, deployments.id DESC + it 'avoids scoping with IN clause during preload' do + control = ActiveRecord::QueryRecorder.new { preload_association(:last_deployment) } + + default_preload_query = control.occurrences_by_line_method.first[1][:occurrences].any? { |i| i.include?('"deployments"."environment_id" IN') } + + expect(default_preload_query).to be(false) + end +end diff --git a/spec/models/project_pages_metadatum_spec.rb b/spec/models/project_pages_metadatum_spec.rb index 31a533e0363..af2f9b94871 100644 --- a/spec/models/project_pages_metadatum_spec.rb +++ b/spec/models/project_pages_metadatum_spec.rb @@ -18,4 +18,15 @@ RSpec.describe ProjectPagesMetadatum do expect(described_class.only_on_legacy_storage).to eq([legacy_storage_project.pages_metadatum]) end end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) do + artifacts_archive = create(:ci_job_artifact, :legacy_archive) + metadatum = artifacts_archive.project.pages_metadatum + metadatum.artifacts_archive = artifacts_archive + metadatum + end + + let!(:parent) { model.artifacts_archive } + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4e38bf7d3e3..2fe50f8c48a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Project, factory_default: :keep do include GitHelpers include ExternalAuthorizationServiceHelpers include ReloadHelpers + include StubGitlabCalls using RSpec::Parameterized::TableSyntax let_it_be(:namespace) { create_default(:namespace).freeze } @@ -379,6 +380,7 @@ RSpec.describe Project, factory_default: :keep do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:namespace_id) } it { is_expected.to validate_length_of(:name).is_at_most(255) } + it { is_expected.not_to allow_value('colon:in:path').for(:path) } # This is to validate that a specially crafted name cannot bypass a pattern match. See !72555 it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_length_of(:path).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(2000) } @@ -1298,7 +1300,7 @@ RSpec.describe Project, factory_default: :keep do end end - describe '#default_owner' do + describe '#first_owner' do let_it_be(:owner) { create(:user) } let_it_be(:namespace) { create(:namespace, owner: owner) } @@ -1306,7 +1308,7 @@ RSpec.describe Project, factory_default: :keep do let(:project) { build(:project, namespace: namespace) } it 'is the namespace owner' do - expect(project.default_owner).to eq(owner) + expect(project.first_owner).to eq(owner) end end @@ -1315,9 +1317,9 @@ RSpec.describe Project, factory_default: :keep do let(:project) { build(:project, group: group, namespace: namespace) } it 'is the group owner' do - allow(group).to receive(:default_owner).and_return(Object.new) + allow(group).to receive(:first_owner).and_return(Object.new) - expect(project.default_owner).to eq(group.default_owner) + expect(project.first_owner).to eq(group.first_owner) end end end @@ -1358,51 +1360,51 @@ RSpec.describe Project, factory_default: :keep do project.reload.has_external_issue_tracker end - it 'is false when external issue tracker service is not active' do - create(:service, project: project, category: 'issue_tracker', active: false) + it 'is false when external issue tracker integration is not active' do + create(:integration, project: project, category: 'issue_tracker', active: false) is_expected.to eq(false) end - it 'is false when other service is active' do - create(:service, project: project, category: 'not_issue_tracker', active: true) + it 'is false when other integration is active' do + create(:integration, project: project, category: 'not_issue_tracker', active: true) is_expected.to eq(false) end - context 'when there is an active external issue tracker service' do - let!(:service) do - create(:service, project: project, type: 'JiraService', category: 'issue_tracker', active: true) + context 'when there is an active external issue tracker integration' do + let!(:integration) do + create(:integration, project: project, type: 'JiraService', category: 'issue_tracker', active: true) end specify { is_expected.to eq(true) } - it 'becomes false when external issue tracker service is destroyed' do + it 'becomes false when external issue tracker integration is destroyed' do expect do - Integration.find(service.id).delete + Integration.find(integration.id).delete end.to change { subject }.to(false) end - it 'becomes false when external issue tracker service becomes inactive' do + it 'becomes false when external issue tracker integration becomes inactive' do expect do - service.update_column(:active, false) + integration.update_column(:active, false) end.to change { subject }.to(false) end - context 'when there are two active external issue tracker services' do - let_it_be(:second_service) do - create(:service, project: project, type: 'CustomIssueTracker', category: 'issue_tracker', active: true) + context 'when there are two active external issue tracker integrations' do + let_it_be(:second_integration) do + create(:integration, project: project, type: 'CustomIssueTracker', category: 'issue_tracker', active: true) end - it 'does not become false when external issue tracker service is destroyed' do + it 'does not become false when external issue tracker integration is destroyed' do expect do - Integration.find(service.id).delete + Integration.find(integration.id).delete end.not_to change { subject } end - it 'does not become false when external issue tracker service becomes inactive' do + it 'does not become false when external issue tracker integration becomes inactive' do expect do - service.update_column(:active, false) + integration.update_column(:active, false) end.not_to change { subject } end end @@ -1454,13 +1456,13 @@ RSpec.describe Project, factory_default: :keep do specify { expect(has_external_wiki).to eq(true) } - it 'becomes false if the external wiki service is destroyed' do + it 'becomes false if the external wiki integration is destroyed' do expect do Integration.find(integration.id).delete end.to change { has_external_wiki }.to(false) end - it 'becomes false if the external wiki service becomes inactive' do + it 'becomes false if the external wiki integration becomes inactive' do expect do integration.update_column(:active, false) end.to change { has_external_wiki }.to(false) @@ -4580,11 +4582,25 @@ RSpec.describe Project, factory_default: :keep do include ProjectHelpers let_it_be(:group) { create(:group) } + let_it_be_with_reload(:project) { create(:project, namespace: group) } - let!(:project) { create(:project, project_level, namespace: group ) } let(:user) { create_user_from_membership(project, membership) } - context 'reporter level access' do + subject { described_class.filter_by_feature_visibility(feature, user) } + + shared_examples 'filter respects visibility' do + it 'respects visibility' do + enable_admin_mode!(user) if admin_mode + project.update!(visibility_level: Gitlab::VisibilityLevel.level_value(project_level.to_s)) + update_feature_access_level(project, feature_access_level) + + expected_objects = expected_count == 1 ? [project] : [] + + expect(subject).to eq(expected_objects) + end + end + + context 'with reporter level access' do let(:feature) { MergeRequest } where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do @@ -4592,20 +4608,11 @@ RSpec.describe Project, factory_default: :keep do end with_them do - it "respects visibility" do - enable_admin_mode!(user) if admin_mode - update_feature_access_level(project, feature_access_level) - - expected_objects = expected_count == 1 ? [project] : [] - - expect( - described_class.filter_by_feature_visibility(feature, user) - ).to eq(expected_objects) - end + it_behaves_like 'filter respects visibility' end end - context 'issues' do + context 'with feature issues' do let(:feature) { Issue } where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do @@ -4613,20 +4620,11 @@ RSpec.describe Project, factory_default: :keep do end with_them do - it "respects visibility" do - enable_admin_mode!(user) if admin_mode - update_feature_access_level(project, feature_access_level) - - expected_objects = expected_count == 1 ? [project] : [] - - expect( - described_class.filter_by_feature_visibility(feature, user) - ).to eq(expected_objects) - end + it_behaves_like 'filter respects visibility' end end - context 'wiki' do + context 'with feature wiki' do let(:feature) { :wiki } where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do @@ -4634,20 +4632,11 @@ RSpec.describe Project, factory_default: :keep do end with_them do - it "respects visibility" do - enable_admin_mode!(user) if admin_mode - update_feature_access_level(project, feature_access_level) - - expected_objects = expected_count == 1 ? [project] : [] - - expect( - described_class.filter_by_feature_visibility(feature, user) - ).to eq(expected_objects) - end + it_behaves_like 'filter respects visibility' end end - context 'code' do + context 'with feature code' do let(:feature) { :repository } where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do @@ -4655,16 +4644,7 @@ RSpec.describe Project, factory_default: :keep do end with_them do - it "respects visibility" do - enable_admin_mode!(user) if admin_mode - update_feature_access_level(project, feature_access_level) - - expected_objects = expected_count == 1 ? [project] : [] - - expect( - described_class.filter_by_feature_visibility(feature, user) - ).to eq(expected_objects) - end + it_behaves_like 'filter respects visibility' end end end @@ -6835,7 +6815,7 @@ RSpec.describe Project, factory_default: :keep do describe 'with integrations and chat names' do subject { create(:project) } - let(:integration) { create(:service, project: subject) } + let(:integration) { create(:integration, project: subject) } before do create_list(:chat_name, 5, integration: integration) @@ -7476,6 +7456,258 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#enforced_runner_token_expiration_interval and #effective_runner_token_expiration_interval' do + shared_examples 'no enforced expiration interval' do + it { expect(subject.enforced_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'enforced expiration interval' do |enforced_interval:| + it { expect(subject.enforced_runner_token_expiration_interval).to eq(enforced_interval) } + end + + shared_examples 'no effective expiration interval' do + it { expect(subject.effective_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'effective expiration interval' do |effective_interval:| + it { expect(subject.effective_runner_token_expiration_interval).to eq(effective_interval) } + end + + context 'when there is no interval' do + let_it_be(:project) { create(:project) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a project interval' do + let_it_be(:project) { create(:project, runner_token_expiration_interval: 3.days.to_i) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + # runner_token_expiration_interval should not affect the expiration interval, only + # project_runner_token_expiration_interval should. + context 'when there is a site-wide enforced shared interval' do + before do + stub_application_setting(runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:project) { create(:project) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + # group_runner_token_expiration_interval should not affect the expiration interval, only + # project_runner_token_expiration_interval should. + context 'when there is a site-wide enforced group interval' do + before do + stub_application_setting(group_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:project) { create(:project) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a site-wide enforced project interval' do + before do + stub_application_setting(project_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:project) { create(:project) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 5.days + it_behaves_like 'effective expiration interval', effective_interval: 5.days + end + + # runner_token_expiration_interval should not affect the expiration interval, only + # project_runner_token_expiration_interval should. + context 'when there is a group-enforced group interval' do + let_it_be(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + # subgroup_runner_token_expiration_interval should not affect the expiration interval, only + # project_runner_token_expiration_interval should. + context 'when there is a group-enforced subgroup interval' do + let_it_be(:group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is an owner group-enforced project interval' do + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 4.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + context 'when there is a grandparent group-enforced interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 3.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group_settings) { create(:namespace_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group, namespace_settings: parent_group_settings) } + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, parent: parent_group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 3.days + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + context 'when there is a parent group-enforced interval overridden by group-enforced interval' do + let_it_be(:parent_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 5.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, parent: parent_group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 4.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + context 'when site-wide enforced interval overrides project interval' do + before do + stub_application_setting(project_runner_token_expiration_interval: 3.days.to_i) + end + + let_it_be(:project) { create(:project, runner_token_expiration_interval: 4.days.to_i) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 3.days + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + context 'when project interval overrides site-wide enforced interval' do + before do + stub_application_setting(project_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:project) { create(:project, runner_token_expiration_interval: 4.days.to_i) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 5.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + + it 'has human-readable expiration intervals' do + expect(subject.enforced_runner_token_expiration_interval_human_readable).to eq('5d') + expect(subject.effective_runner_token_expiration_interval_human_readable).to eq('4d') + end + end + + context 'when site-wide enforced interval overrides group-enforced interval' do + before do + stub_application_setting(project_runner_token_expiration_interval: 3.days.to_i) + end + + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 3.days + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + context 'when group-enforced interval overrides site-wide enforced interval' do + before do + stub_application_setting(project_runner_token_expiration_interval: 5.days.to_i) + end + + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 4.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + context 'when group-enforced interval overrides project interval' do + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 3.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group, runner_token_expiration_interval: 4.days.to_i) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 3.days + it_behaves_like 'effective expiration interval', effective_interval: 3.days + end + + context 'when project interval overrides group-enforced interval' do + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 5.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + let_it_be(:project) { create(:project, group: group, runner_token_expiration_interval: 4.days.to_i) } + + subject { project } + + it_behaves_like 'enforced expiration interval', enforced_interval: 5.days + it_behaves_like 'effective expiration interval', effective_interval: 4.days + end + + # Unrelated groups should not affect the expiration interval. + context 'when there is an enforced project interval in an unrelated group' do + let_it_be(:unrelated_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:unrelated_group) { create(:group, namespace_settings: unrelated_group_settings) } + let_it_be(:project) { create(:project) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + # Subgroups should not affect the parent group expiration interval. + context 'when there is an enforced project interval in a subgroup' do + let_it_be(:group) { create(:group) } + let_it_be(:subgroup_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:subgroup) { create(:group, parent: group, namespace_settings: subgroup_settings) } + let_it_be(:project) { create(:project, group: group) } + + subject { project } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + end + it_behaves_like 'it has loose foreign keys' do let(:factory_name) { :project } end @@ -7551,6 +7783,46 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#context_commits_enabled?' do + let_it_be(:project) { create(:project) } + + subject(:result) { project.context_commits_enabled? } + + context 'when context_commits feature flag is enabled' do + before do + stub_feature_flags(context_commits: true) + end + + it { is_expected.to be_truthy } + end + + context 'when context_commits feature flag is disabled' do + before do + stub_feature_flags(context_commits: false) + end + + it { is_expected.to be_falsey } + end + + context 'when context_commits feature flag is enabled on this project' do + before do + stub_feature_flags(context_commits: project) + end + + it { is_expected.to be_truthy } + end + + context 'when context_commits feature flag is enabled on another project' do + let(:another_project) { create(:project) } + + before do + stub_feature_flags(context_commits: another_project) + end + + it { is_expected.to be_falsey } + end + end + private def finish_job(export_job) diff --git a/spec/models/protectable_dropdown_spec.rb b/spec/models/protectable_dropdown_spec.rb index ab3f455fe63..a256f9e0ab1 100644 --- a/spec/models/protectable_dropdown_spec.rb +++ b/spec/models/protectable_dropdown_spec.rb @@ -3,8 +3,9 @@ require 'spec_helper' RSpec.describe ProtectableDropdown do + subject(:dropdown) { described_class.new(project, ref_type) } + let(:project) { create(:project, :repository) } - let(:subject) { described_class.new(project, :branches) } describe 'initialize' do it 'raises ArgumentError for invalid ref type' do @@ -13,34 +14,75 @@ RSpec.describe ProtectableDropdown do end end - describe '#protectable_ref_names' do + shared_examples 'protectable_ref_names' do context 'when project repository is not empty' do - before do - create(:protected_branch, project: project, name: 'master') - end - - it { expect(subject.protectable_ref_names).to include('feature') } - it { expect(subject.protectable_ref_names).not_to include('master') } + it 'includes elements matching a protected ref wildcard' do + is_expected.to include(matching_ref) - it "includes branches matching a protected branch wildcard" do - expect(subject.protectable_ref_names).to include('feature') + factory = ref_type == :branches ? :protected_branch : :protected_tag - create(:protected_branch, name: 'feat*', project: project) + create(factory, name: "#{matching_ref[0]}*", project: project) - subject = described_class.new(project.reload, :branches) + subject = described_class.new(project.reload, ref_type) - expect(subject.protectable_ref_names).to include('feature') + expect(subject.protectable_ref_names).to include(matching_ref) end end context 'when project repository is empty' do let(:project) { create(:project) } - it "returns empty list" do - subject = described_class.new(project, :branches) + it 'returns empty list' do + is_expected.to be_empty + end + end + end + + describe '#protectable_ref_names' do + subject { dropdown.protectable_ref_names } + + context 'for branches' do + let(:ref_type) { :branches } + let(:matching_ref) { 'feature' } - expect(subject.protectable_ref_names).to be_empty + before do + create(:protected_branch, project: project, name: 'master') end + + it { is_expected.to include(matching_ref) } + it { is_expected.not_to include('master') } + + it_behaves_like 'protectable_ref_names' + end + + context 'for tags' do + let(:ref_type) { :tags } + let(:matching_ref) { 'v1.0.0' } + + before do + create(:protected_tag, project: project, name: 'v1.1.0') + end + + it { is_expected.to include(matching_ref) } + it { is_expected.not_to include('v1.1.0') } + + it_behaves_like 'protectable_ref_names' + end + end + + describe '#hash' do + subject { dropdown.hash } + + context 'for branches' do + let(:ref_type) { :branches } + + it { is_expected.to include(id: 'feature', text: 'feature', title: 'feature') } + end + + context 'for tags' do + let(:ref_type) { :tags } + + it { is_expected.to include(id: 'v1.0.0', text: 'v1.0.0', title: 'v1.0.0') } end end end diff --git a/spec/models/ref_matcher_spec.rb b/spec/models/ref_matcher_spec.rb new file mode 100644 index 00000000000..47a6a8b986c --- /dev/null +++ b/spec/models/ref_matcher_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RefMatcher do + subject(:ref_matcher) { described_class.new(ref_pattern) } + + let(:ref_pattern) { 'v1.0' } + + shared_examples 'matching_refs' do + context 'when there is no match' do + let(:ref_pattern) { 'unknown' } + + it { is_expected.to match_array([]) } + end + + context 'when ref pattern is a wildcard' do + let(:ref_pattern) { 'v*' } + + it { is_expected.to match_array(refs) } + end + end + + describe '#matching' do + subject { ref_matcher.matching(refs) } + + context 'when refs are strings' do + let(:refs) { ['v1.0', 'v1.1'] } + + it { is_expected.to match_array([ref_pattern]) } + + it_behaves_like 'matching_refs' + end + + context 'when refs are ref objects' do + let(:matching_ref) { double('tag', name: 'v1.0') } + let(:not_matching_ref) { double('tag', name: 'v1.1') } + let(:refs) { [matching_ref, not_matching_ref] } + + it { is_expected.to match_array([matching_ref]) } + + it_behaves_like 'matching_refs' + end + end + + describe '#matches?' do + subject { ref_matcher.matches?(ref_name) } + + let(:ref_name) { 'v1.0' } + + it { is_expected.to be_truthy } + + context 'when ref_name is empty' do + let(:ref_name) { '' } + + it { is_expected.to be_falsey } + end + + context 'when ref pattern matches wildcard' do + let(:ref_pattern) { 'v*' } + + it { is_expected.to be_truthy } + end + + context 'when ref pattern does not match wildcard' do + let(:ref_pattern) { 'v2.*' } + + it { is_expected.to be_falsey } + end + end + + describe '#wildcard?' do + subject { ref_matcher.wildcard? } + + it { is_expected.to be_falsey } + + context 'when pattern is a wildcard' do + let(:ref_pattern) { 'v*' } + + it { is_expected.to be_truthy } + end + end +end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 96cbdb468aa..e592a4964f5 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -2398,17 +2398,6 @@ RSpec.describe Repository do it 'returns nil when tag does not exists' do expect(repository.find_tag('does-not-exist')).to be_nil end - - context 'when find_tag_via_gitaly is disabled' do - it 'fetches all tags' do - stub_feature_flags(find_tag_via_gitaly: false) - - expect(Gitlab::GitalyClient) - .to receive(:call).with(anything, :ref_service, :find_all_tags, anything, anything).and_call_original - - expect(repository.find_tag('v1.1.0').name).to eq('v1.1.0') - end - end end describe '#avatar' do diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index b2fa9c24535..0489a4fb995 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Route do describe 'relationships' do it { is_expected.to belong_to(:source) } + it { is_expected.to belong_to(:namespace) } end describe 'validations' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index f8cea619233..ac2474ac393 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -83,6 +83,9 @@ RSpec.describe User do it { is_expected.to delegate_method(:registration_objective).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:registration_objective=).to(:user_detail).with_arguments(:args).allow_nil } + + it { is_expected.to delegate_method(:requires_credit_card_verification).to(:user_detail).allow_nil } + it { is_expected.to delegate_method(:requires_credit_card_verification=).to(:user_detail).with_arguments(:args).allow_nil } end describe 'associations' do @@ -436,7 +439,7 @@ RSpec.describe User do subject { build(:user) } end - it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :public_email, :notification_email do + it_behaves_like 'an object with email-formatted attributes', :public_email, :notification_email do subject { create(:user).tap { |user| user.emails << build(:email, email: email_value, confirmed_at: Time.current) } } end @@ -542,6 +545,13 @@ RSpec.describe User do expect(user).to be_invalid expect(user.errors.messages[:email].first).to eq(expected_error) end + + it 'does not allow user to update email to a non-allowlisted domain' do + user = create(:user, email: "info@test.example.com") + + expect { user.update!(email: "test@notexample.com") } + .to raise_error(StandardError, 'Validation failed: Email is not allowed. Check with your administrator.') + end end context 'when a signup domain is allowed and subdomains are not allowed' do @@ -608,6 +618,13 @@ RSpec.describe User do user = build(:user, email: 'info@example.com', created_by_id: 1) expect(user).to be_valid end + + it 'does not allow user to update email to a denied domain' do + user = create(:user, email: 'info@test.com') + + expect { user.update!(email: 'info@example.com') } + .to raise_error(StandardError, 'Validation failed: Email is not allowed. Check with your administrator.') + end end context 'when a signup domain is denied but a wildcard subdomain is allowed' do @@ -679,6 +696,13 @@ RSpec.describe User do expect(user.errors.messages[:email].first).to eq(expected_error) end + it 'does not allow user to update email to a restricted domain' do + user = create(:user, email: 'info@test.com') + + expect { user.update!(email: 'info@gitlab.com') } + .to raise_error(StandardError, 'Validation failed: Email is not allowed. Check with your administrator.') + end + it 'does accept a valid email address' do user = build(:user, email: 'info@test.com') @@ -1398,7 +1422,7 @@ RSpec.describe User do end describe '#update_tracked_fields!', :clean_gitlab_redis_shared_state do - let(:request) { OpenStruct.new(remote_ip: "127.0.0.1") } + let(:request) { double('request', remote_ip: "127.0.0.1") } let(:user) { create(:user) } it 'writes trackable attributes' do @@ -1481,27 +1505,176 @@ RSpec.describe User do end describe '#confirm' do + let(:expired_confirmation_sent_at) { Date.today - described_class.confirm_within - 7.days } + let(:extant_confirmation_sent_at) { Date.today } + before do allow_any_instance_of(ApplicationSetting).to receive(:send_user_confirmation_email).and_return(true) end - let(:user) { create(:user, :unconfirmed, unconfirmed_email: 'test@gitlab.com') } + let(:user) do + create(:user, :unconfirmed, unconfirmed_email: 'test@gitlab.com').tap do |user| + user.update!(confirmation_sent_at: confirmation_sent_at) + end + end - it 'returns unconfirmed' do - expect(user.confirmed?).to be_falsey + shared_examples_for 'unconfirmed user' do + it 'returns unconfirmed' do + expect(user.confirmed?).to be_falsey + end end - it 'confirms a user' do - user.confirm - expect(user.confirmed?).to be_truthy + context 'when the confirmation period has expired' do + let(:confirmation_sent_at) { expired_confirmation_sent_at } + + it_behaves_like 'unconfirmed user' + + it 'does not confirm the user' do + user.confirm + + expect(user.confirmed?).to be_falsey + end + + it 'does not add the confirmed primary email to emails' do + user.confirm + + expect(user.emails.confirmed.map(&:email)).not_to include(user.email) + end end - it 'adds the confirmed primary email to emails' do - expect(user.emails.confirmed.map(&:email)).not_to include(user.email) + context 'when the confirmation period has not expired' do + let(:confirmation_sent_at) { extant_confirmation_sent_at } - user.confirm + it_behaves_like 'unconfirmed user' - expect(user.emails.confirmed.map(&:email)).to include(user.email) + it 'confirms a user' do + user.confirm + expect(user.confirmed?).to be_truthy + end + + it 'adds the confirmed primary email to emails' do + expect(user.emails.confirmed.map(&:email)).not_to include(user.email) + + user.confirm + + expect(user.emails.confirmed.map(&:email)).to include(user.email) + end + + context 'when the primary email is already included in user.emails' do + let(:expired_confirmation_sent_at_for_email) { Date.today - Email.confirm_within - 7.days } + let(:extant_confirmation_sent_at_for_email) { Date.today } + + let!(:email) do + create(:email, email: user.unconfirmed_email, user: user).tap do |email| + email.update!(confirmation_sent_at: confirmation_sent_at_for_email) + end + end + + context 'when the confirmation period of the email record has expired' do + let(:confirmation_sent_at_for_email) { expired_confirmation_sent_at_for_email } + + it 'does not confirm the email record' do + user.confirm + + expect(email.reload.confirmed?).to be_falsey + end + end + + context 'when the confirmation period of the email record has not expired' do + let(:confirmation_sent_at_for_email) { extant_confirmation_sent_at_for_email } + + it 'confirms the email record' do + user.confirm + + expect(email.reload.confirmed?).to be_truthy + end + end + end + end + end + + describe '#force_confirm' do + let(:expired_confirmation_sent_at) { Date.today - described_class.confirm_within - 7.days } + let(:extant_confirmation_sent_at) { Date.today } + + let(:user) do + create(:user, :unconfirmed, unconfirmed_email: 'test@gitlab.com').tap do |user| + user.update!(confirmation_sent_at: confirmation_sent_at) + end + end + + shared_examples_for 'unconfirmed user' do + it 'returns unconfirmed' do + expect(user.confirmed?).to be_falsey + end + end + + shared_examples_for 'confirms the user on force_confirm' do + it 'confirms a user' do + user.force_confirm + expect(user.confirmed?).to be_truthy + end + end + + shared_examples_for 'adds the confirmed primary email to emails' do + it 'adds the confirmed primary email to emails' do + expect(user.emails.confirmed.map(&:email)).not_to include(user.email) + + user.force_confirm + + expect(user.emails.confirmed.map(&:email)).to include(user.email) + end + end + + shared_examples_for 'confirms the email record if the primary email was already present in user.emails' do + context 'when the primary email is already included in user.emails' do + let(:expired_confirmation_sent_at_for_email) { Date.today - Email.confirm_within - 7.days } + let(:extant_confirmation_sent_at_for_email) { Date.today } + + let!(:email) do + create(:email, email: user.unconfirmed_email, user: user).tap do |email| + email.update!(confirmation_sent_at: confirmation_sent_at_for_email) + end + end + + shared_examples_for 'confirms the email record' do + it 'confirms the email record' do + user.force_confirm + + expect(email.reload.confirmed?).to be_truthy + end + end + + context 'when the confirmation period of the email record has expired' do + let(:confirmation_sent_at_for_email) { expired_confirmation_sent_at_for_email } + + it_behaves_like 'confirms the email record' + end + + context 'when the confirmation period of the email record has not expired' do + let(:confirmation_sent_at_for_email) { extant_confirmation_sent_at_for_email } + + it_behaves_like 'confirms the email record' + end + end + end + + context 'when the confirmation period has expired' do + let(:confirmation_sent_at) { expired_confirmation_sent_at } + + it_behaves_like 'unconfirmed user' + it_behaves_like 'confirms the user on force_confirm' + it_behaves_like 'adds the confirmed primary email to emails' + it_behaves_like 'confirms the email record if the primary email was already present in user.emails' + end + + context 'when the confirmation period has not expired' do + let(:confirmation_sent_at) { extant_confirmation_sent_at } + + it_behaves_like 'unconfirmed user' + it_behaves_like 'confirms the user on force_confirm' + it_behaves_like 'adds the confirmed primary email to emails' + it_behaves_like 'confirms the email record if the primary email was already present in user.emails' end end @@ -1523,9 +1696,9 @@ RSpec.describe User do describe '#generate_password' do it 'does not generate password by default' do - user = create(:user, password: 'abcdefghe') + user = create(:user, password: Gitlab::Password.test_default) - expect(user.password).to eq('abcdefghe') + expect(user.password).to eq(Gitlab::Password.test_default) end end @@ -1624,6 +1797,29 @@ RSpec.describe User do expect(static_object_token).not_to be_blank expect(user.reload.static_object_token).to eq static_object_token end + + it 'generates an encrypted version of the token' do + user = create(:user, static_object_token: nil) + + expect(user[:static_object_token]).to be_nil + expect(user[:static_object_token_encrypted]).to be_nil + + user.static_object_token + + expect(user[:static_object_token]).to be_nil + expect(user[:static_object_token_encrypted]).to be_present + end + + it 'prefers an encoded version of the token' do + user = create(:user, static_object_token: nil) + + token = user.static_object_token + + user.update_column(:static_object_token, 'Test') + + expect(user.static_object_token).not_to eq('Test') + expect(user.static_object_token).to eq(token) + end end describe 'enabled_static_object_token' do @@ -1862,7 +2058,7 @@ RSpec.describe User do it { expect(user.authorized_groups).to eq([group]) } it { expect(user.owned_groups).to eq([group]) } it { expect(user.namespaces).to contain_exactly(user.namespace, group) } - it { expect(user.manageable_namespaces).to contain_exactly(user.namespace, group) } + it { expect(user.forkable_namespaces).to contain_exactly(user.namespace, group) } context 'with owned groups only' do before do @@ -1876,9 +2072,12 @@ RSpec.describe User do context 'with child groups' do let!(:subgroup) { create(:group, parent: group) } - describe '#manageable_namespaces' do - it 'includes all the namespaces the user can manage' do - expect(user.manageable_namespaces).to contain_exactly(user.namespace, group, subgroup) + describe '#forkable_namespaces' do + it 'includes all the namespaces the user can fork into' do + developer_group = create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) + developer_group.add_developer(user) + + expect(user.forkable_namespaces).to contain_exactly(user.namespace, group, subgroup, developer_group) end end @@ -2592,6 +2791,12 @@ RSpec.describe User do end end + describe '.user_search_minimum_char_limit' do + it 'returns true' do + expect(described_class.user_search_minimum_char_limit).to be(true) + end + end + describe '.find_by_ssh_key_id' do let_it_be(:user) { create(:user) } let_it_be(:key) { create(:key, user: user) } @@ -3768,7 +3973,7 @@ RSpec.describe User do end end - describe '#ci_owned_runners' do + shared_context '#ci_owned_runners' do let(:user) { create(:user) } shared_examples :nested_groups_owner do @@ -4075,6 +4280,16 @@ RSpec.describe User do end end + it_behaves_like '#ci_owned_runners' + + context 'when FF ci_owned_runners_cross_joins_fix is disabled' do + before do + stub_feature_flags(ci_owned_runners_cross_joins_fix: false) + end + + it_behaves_like '#ci_owned_runners' + end + describe '#projects_with_reporter_access_limited_to' do let(:project1) { create(:project) } let(:project2) { create(:project) } @@ -5606,6 +5821,48 @@ RSpec.describe User do end end + describe '#can_log_in_with_non_expired_password?' do + let(:user) { build(:user) } + + subject { user.can_log_in_with_non_expired_password? } + + context 'when user can log in' do + it 'returns true' do + is_expected.to be_truthy + end + + context 'when user with expired password' do + before do + user.password_expires_at = 2.minutes.ago + end + + it 'returns false' do + is_expected.to be_falsey + end + + context 'when password expiration is not applicable' do + context 'when ldap user' do + let(:user) { build(:omniauth_user, provider: 'ldap') } + + it 'returns true' do + is_expected.to be_truthy + end + end + end + end + end + + context 'when user cannot log in' do + context 'when user is blocked' do + let(:user) { build(:user, :blocked) } + + it 'returns false' do + is_expected.to be_falsey + end + end + end + end + describe '#read_only_attribute?' do context 'when synced attributes metadata is present' do it 'delegates to synced_attributes_metadata' do @@ -6303,13 +6560,43 @@ RSpec.describe User do specify { is_expected.to contain_exactly(developer_group2) } end - describe '.get_ids_by_username' do + describe '.get_ids_by_ids_or_usernames' do let(:user_name) { 'user_name' } let!(:user) { create(:user, username: user_name) } let(:user_id) { user.id } it 'returns the id of each record matching username' do - expect(described_class.get_ids_by_username([user_name])).to match_array([user_id]) + expect(described_class.get_ids_by_ids_or_usernames(nil, [user_name])).to match_array([user_id]) + end + + it 'returns the id of each record matching user id' do + expect(described_class.get_ids_by_ids_or_usernames([user_id], nil)).to match_array([user_id]) + end + + it 'return the id for all records matching either user id or user name' do + new_user_id = create(:user).id + + expect(described_class.get_ids_by_ids_or_usernames([new_user_id], [user_name])).to match_array([user_id, new_user_id]) + end + end + + describe '.by_ids_or_usernames' do + let(:user_name) { 'user_name' } + let!(:user) { create(:user, username: user_name) } + let(:user_id) { user.id } + + it 'returns matching records based on username' do + expect(described_class.by_ids_or_usernames(nil, [user_name])).to match_array([user]) + end + + it 'returns matching records based on id' do + expect(described_class.by_ids_or_usernames([user_id], nil)).to match_array([user]) + end + + it 'returns matching records based on both username and id' do + new_user = create(:user) + + expect(described_class.by_ids_or_usernames([new_user.id], [user_name])).to match_array([user, new_user]) end end diff --git a/spec/models/users_statistics_spec.rb b/spec/models/users_statistics_spec.rb index 8553d0bfdb0..add9bd18755 100644 --- a/spec/models/users_statistics_spec.rb +++ b/spec/models/users_statistics_spec.rb @@ -43,7 +43,7 @@ RSpec.describe UsersStatistics do create_list(:user, 2, :bot) create_list(:user, 1, :blocked) - allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false) + allow(described_class.connection).to receive(:transaction_open?).and_return(false) end context 'when successful' do diff --git a/spec/models/work_item/type_spec.rb b/spec/models/work_items/type_spec.rb index cc18558975b..6e9f3210e65 100644 --- a/spec/models/work_item/type_spec.rb +++ b/spec/models/work_items/type_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe WorkItem::Type do +RSpec.describe WorkItems::Type do describe 'modules' do it { is_expected.to include_module(CacheMarkdownField) } end @@ -12,6 +12,22 @@ RSpec.describe WorkItem::Type do it { is_expected.to belong_to(:namespace) } end + describe 'scopes' do + describe 'order_by_name_asc' do + subject { described_class.order_by_name_asc.pluck(:name) } + + before do + # Deletes all so we have control on the entire list of names + described_class.delete_all + create(:work_item_type, name: 'Ztype') + create(:work_item_type, name: 'atype') + create(:work_item_type, name: 'gtype') + end + + it { is_expected.to match(%w[atype gtype Ztype]) } + end + end + describe '#destroy' do let!(:work_item) { create :issue } @@ -19,10 +35,10 @@ RSpec.describe WorkItem::Type do it 'deletes type but not unrelated issues' do type = create(:work_item_type) - expect(WorkItem::Type.count).to eq(6) + expect(WorkItems::Type.count).to eq(6) expect { type.destroy! }.not_to change(Issue, :count) - expect(WorkItem::Type.count).to eq(5) + expect(WorkItems::Type.count).to eq(5) end end @@ -44,6 +60,22 @@ RSpec.describe WorkItem::Type do it { is_expected.not_to allow_value('s' * 256).for(:icon_name) } end + describe 'default?' do + subject { build(:work_item_type, namespace: namespace).default? } + + context 'when namespace is nil' do + let(:namespace) { nil } + + it { is_expected.to be_truthy } + end + + context 'when namespace is present' do + let(:namespace) { build(:namespace) } + + it { is_expected.to be_falsey } + end + end + describe '#name' do it 'strips name' do work_item_type = described_class.new(name: ' label😸 ') diff --git a/spec/policies/blob_policy_spec.rb b/spec/policies/blob_policy_spec.rb index daabcd844af..2b0465f3615 100644 --- a/spec/policies/blob_policy_spec.rb +++ b/spec/policies/blob_policy_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe BlobPolicy do include_context 'ProjectPolicyTable context' include ProjectHelpers - using RSpec::Parameterized::TableSyntax - let(:project) { create(:project, :repository, project_level) } + let_it_be_with_reload(:project) { create(:project, :repository) } + let(:user) { create_user_from_membership(project, membership) } let(:blob) { project.repository.blob_at(SeedRepo::FirstCommit::ID, 'README.md') } @@ -18,8 +18,9 @@ RSpec.describe BlobPolicy do end with_them do - it "grants permission" do + it 'grants permission' do enable_admin_mode!(user) if admin_mode + project.update!(visibility_level: Gitlab::VisibilityLevel.level_value(project_level.to_s)) update_feature_access_level(project, feature_access_level) if expected_count == 1 diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb index d283b0ffda5..50774313aae 100644 --- a/spec/policies/group_member_policy_spec.rb +++ b/spec/policies/group_member_policy_spec.rb @@ -83,6 +83,23 @@ RSpec.describe GroupMemberPolicy do specify { expect_allowed(:read_group) } end + context 'with bot user' do + let(:current_user) { create(:user, :project_bot) } + + before do + group.add_owner(current_user) + end + + specify { expect_allowed(:read_group, :destroy_project_bot_member) } + end + + context 'with anonymous bot user' do + let(:current_user) { create(:user, :project_bot) } + let(:membership) { guest.members.first } + + specify { expect_disallowed(:read_group, :destroy_project_bot_member) } + end + context 'with one owner' do let(:current_user) { owner } @@ -106,6 +123,7 @@ RSpec.describe GroupMemberPolicy do end specify { expect_allowed(*member_related_permissions) } + specify { expect_disallowed(:destroy_project_bot_member) } end context 'with the group parent' do diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 7822ee2b92e..2607e285a80 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -6,15 +6,11 @@ RSpec.describe GroupPolicy do include_context 'GroupPolicy context' context 'public group with no user' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } let(:current_user) { nil } it do - expect_allowed(:read_group) - expect_allowed(:read_crm_organization) - expect_allowed(:read_crm_contact) - expect_allowed(:read_counts) - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_disallowed(:upload_file) expect_disallowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -24,34 +20,49 @@ RSpec.describe GroupPolicy do end end - context 'with no user and public project' do - let(:project) { create(:project, :public) } + context 'public group with user who is not a member' do + let(:group) { create(:group, :public, :crm_enabled) } + let(:current_user) { create(:user) } + + it do + expect_allowed(*public_permissions) + expect_disallowed(:upload_file) + expect_disallowed(*reporter_permissions) + expect_disallowed(*developer_permissions) + expect_disallowed(*maintainer_permissions) + expect_disallowed(*owner_permissions) + expect_disallowed(:read_namespace) + end + end + + context 'private group that has been invited to a public project and with no user' do + let(:project) { create(:project, :public, group: create(:group, :crm_enabled)) } let(:current_user) { nil } before do create(:project_group_link, project: project, group: group) end - it { expect_disallowed(:read_group) } - it { expect_disallowed(:read_crm_organization) } - it { expect_disallowed(:read_crm_contact) } - it { expect_disallowed(:read_counts) } - it { expect_disallowed(*read_group_permissions) } + it do + expect_disallowed(*public_permissions) + expect_disallowed(*reporter_permissions) + expect_disallowed(*owner_permissions) + end end - context 'with foreign user and public project' do - let(:project) { create(:project, :public) } + context 'private group that has been invited to a public project and with a foreign user' do + let(:project) { create(:project, :public, group: create(:group, :crm_enabled)) } let(:current_user) { create(:user) } before do create(:project_group_link, project: project, group: group) end - it { expect_disallowed(:read_group) } - it { expect_disallowed(:read_crm_organization) } - it { expect_disallowed(:read_crm_contact) } - it { expect_disallowed(:read_counts) } - it { expect_disallowed(*read_group_permissions) } + it do + expect_disallowed(*public_permissions) + expect_disallowed(*reporter_permissions) + expect_disallowed(*owner_permissions) + end end context 'has projects' do @@ -62,13 +73,13 @@ RSpec.describe GroupPolicy do project.add_developer(current_user) end - it { expect_allowed(*read_group_permissions) } + it { expect_allowed(*(public_permissions - [:read_counts])) } context 'in subgroups' do - let(:subgroup) { create(:group, :private, parent: group) } + let(:subgroup) { create(:group, :private, :crm_enabled, parent: group) } let(:project) { create(:project, namespace: subgroup) } - it { expect_allowed(*read_group_permissions) } + it { expect_allowed(*(public_permissions - [:read_counts])) } end end @@ -81,7 +92,7 @@ RSpec.describe GroupPolicy do let(:current_user) { deploy_token } it do - expect_disallowed(*read_group_permissions) + expect_disallowed(*public_permissions) expect_disallowed(*guest_permissions) expect_disallowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -94,7 +105,7 @@ RSpec.describe GroupPolicy do let(:current_user) { guest } it do - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_disallowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -111,7 +122,7 @@ RSpec.describe GroupPolicy do let(:current_user) { reporter } it do - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -128,7 +139,7 @@ RSpec.describe GroupPolicy do let(:current_user) { developer } it do - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -156,7 +167,7 @@ RSpec.describe GroupPolicy do updated_owner_permissions = owner_permissions - create_subgroup_permission - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -167,7 +178,7 @@ RSpec.describe GroupPolicy do context 'with subgroup_creation_level set to owner' do it 'allows every maintainer permission' do - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -185,7 +196,7 @@ RSpec.describe GroupPolicy do let(:current_user) { owner } it do - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -202,7 +213,7 @@ RSpec.describe GroupPolicy do let(:current_user) { admin } specify do - expect_disallowed(*read_group_permissions) + expect_disallowed(*public_permissions) expect_disallowed(*guest_permissions) expect_disallowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -212,7 +223,7 @@ RSpec.describe GroupPolicy do context 'with admin mode', :enable_admin_mode do specify do - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -233,7 +244,7 @@ RSpec.describe GroupPolicy do describe 'private nested group use the highest access level from the group and inherited permissions' do let_it_be(:nested_group) do - create(:group, :private, :owner_subgroup_creation_only, parent: group) + create(:group, :private, :owner_subgroup_creation_only, :crm_enabled, parent: group) end before_all do @@ -254,8 +265,7 @@ RSpec.describe GroupPolicy do let(:current_user) { nil } it do - expect_disallowed(:read_counts) - expect_disallowed(*read_group_permissions) + expect_disallowed(*public_permissions) expect_disallowed(*guest_permissions) expect_disallowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -268,8 +278,7 @@ RSpec.describe GroupPolicy do let(:current_user) { guest } it do - expect_allowed(:read_counts) - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_disallowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -282,8 +291,7 @@ RSpec.describe GroupPolicy do let(:current_user) { reporter } it do - expect_allowed(:read_counts) - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_disallowed(*developer_permissions) @@ -296,8 +304,7 @@ RSpec.describe GroupPolicy do let(:current_user) { developer } it do - expect_allowed(:read_counts) - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -310,8 +317,7 @@ RSpec.describe GroupPolicy do let(:current_user) { maintainer } it do - expect_allowed(:read_counts) - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -324,8 +330,7 @@ RSpec.describe GroupPolicy do let(:current_user) { owner } it do - expect_allowed(:read_counts) - expect_allowed(*read_group_permissions) + expect_allowed(*public_permissions) expect_allowed(*guest_permissions) expect_allowed(*reporter_permissions) expect_allowed(*developer_permissions) @@ -340,7 +345,7 @@ RSpec.describe GroupPolicy do let(:current_user) { owner } context 'when the group share_with_group_lock is enabled' do - let(:group) { create(:group, share_with_group_lock: true, parent: parent) } + let(:group) { create(:group, :crm_enabled, share_with_group_lock: true, parent: parent) } before do group.add_owner(owner) @@ -348,10 +353,10 @@ RSpec.describe GroupPolicy do context 'when the parent group share_with_group_lock is enabled' do context 'when the group has a grandparent' do - let(:parent) { create(:group, share_with_group_lock: true, parent: grandparent) } + let(:parent) { create(:group, :crm_enabled, share_with_group_lock: true, parent: grandparent) } context 'when the grandparent share_with_group_lock is enabled' do - let(:grandparent) { create(:group, share_with_group_lock: true) } + let(:grandparent) { create(:group, :crm_enabled, share_with_group_lock: true) } context 'when the current_user owns the parent' do before do @@ -377,7 +382,7 @@ RSpec.describe GroupPolicy do end context 'when the grandparent share_with_group_lock is disabled' do - let(:grandparent) { create(:group) } + let(:grandparent) { create(:group, :crm_enabled) } context 'when the current_user owns the parent' do before do @@ -394,7 +399,7 @@ RSpec.describe GroupPolicy do end context 'when the group does not have a grandparent' do - let(:parent) { create(:group, share_with_group_lock: true) } + let(:parent) { create(:group, :crm_enabled, share_with_group_lock: true) } context 'when the current_user owns the parent' do before do @@ -411,7 +416,7 @@ RSpec.describe GroupPolicy do end context 'when the parent group share_with_group_lock is disabled' do - let(:parent) { create(:group) } + let(:parent) { create(:group, :crm_enabled) } it { expect_allowed(:change_share_with_group_lock) } end @@ -696,7 +701,7 @@ RSpec.describe GroupPolicy do end it_behaves_like 'clusterable policies' do - let(:clusterable) { create(:group) } + let(:clusterable) { create(:group, :crm_enabled) } let(:cluster) do create(:cluster, :provided_by_gcp, @@ -706,7 +711,7 @@ RSpec.describe GroupPolicy do end describe 'update_max_artifacts_size' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } context 'when no user' do let(:current_user) { nil } @@ -736,7 +741,7 @@ RSpec.describe GroupPolicy do end describe 'design activity' do - let_it_be(:group) { create(:group, :public) } + let_it_be(:group) { create(:group, :public, :crm_enabled) } let(:current_user) { nil } @@ -904,7 +909,6 @@ RSpec.describe GroupPolicy do context 'feature enabled' do before do stub_config(dependency_proxy: { enabled: true }) - group.create_dependency_proxy_setting!(enabled: true) end context 'reporter' do @@ -933,8 +937,6 @@ RSpec.describe GroupPolicy do it { is_expected.to be_allowed(:read_package) } it { is_expected.to be_allowed(:read_group) } - it { is_expected.to be_allowed(:read_crm_organization) } - it { is_expected.to be_allowed(:read_crm_contact) } it { is_expected.to be_disallowed(:create_package) } end @@ -944,8 +946,6 @@ RSpec.describe GroupPolicy do it { is_expected.to be_allowed(:create_package) } it { is_expected.to be_allowed(:read_package) } it { is_expected.to be_allowed(:read_group) } - it { is_expected.to be_allowed(:read_crm_organization) } - it { is_expected.to be_allowed(:read_crm_contact) } it { is_expected.to be_disallowed(:destroy_package) } end @@ -954,7 +954,6 @@ RSpec.describe GroupPolicy do before do stub_config(dependency_proxy: { enabled: true }) - group.create_dependency_proxy_setting!(enabled: true) end it { is_expected.to be_allowed(:read_dependency_proxy) } @@ -965,7 +964,7 @@ RSpec.describe GroupPolicy do it_behaves_like 'Self-managed Core resource access tokens' context 'support bot' do - let_it_be(:group) { create(:group, :private) } + let_it_be(:group) { create(:group, :private, :crm_enabled) } let_it_be(:current_user) { User.support_bot } before do @@ -975,7 +974,7 @@ RSpec.describe GroupPolicy do it { expect_disallowed(:read_label) } context 'when group hierarchy has a project with service desk enabled' do - let_it_be(:subgroup) { create(:group, :private, parent: group)} + let_it_be(:subgroup) { create(:group, :private, :crm_enabled, parent: group) } let_it_be(:project) { create(:project, group: subgroup, service_desk_enabled: true) } it { expect_allowed(:read_label) } @@ -983,6 +982,49 @@ RSpec.describe GroupPolicy do end end + context "project bots" do + let(:project_bot) { create(:user, :project_bot) } + let(:user) { create(:user) } + + context "project_bot_access" do + context "when regular user and part of the group" do + let(:current_user) { user } + + before do + group.add_developer(user) + end + + it { is_expected.not_to be_allowed(:project_bot_access) } + end + + context "when project bot and not part of the project" do + let(:current_user) { project_bot } + + it { is_expected.not_to be_allowed(:project_bot_access) } + end + + context "when project bot and part of the project" do + let(:current_user) { project_bot } + + before do + group.add_developer(project_bot) + end + + it { is_expected.to be_allowed(:project_bot_access) } + end + end + + context 'with resource access tokens' do + let(:current_user) { project_bot } + + before do + group.add_maintainer(project_bot) + end + + it { is_expected.not_to be_allowed(:create_resource_access_tokens) } + end + end + describe 'update_runners_registration_token' do context 'admin' do let(:current_user) { admin } @@ -1083,9 +1125,7 @@ RSpec.describe GroupPolicy do context 'with maintainer' do let(:current_user) { maintainer } - it { is_expected.to be_allowed(:register_group_runners) } - - it_behaves_like 'expected outcome based on runner registration control' + it { is_expected.to be_disallowed(:register_group_runners) } end context 'with reporter' do @@ -1113,7 +1153,7 @@ RSpec.describe GroupPolicy do end end - context 'with customer_relations feature flag disabled' do + context 'with customer relations feature flag disabled' do let(:current_user) { owner } before do @@ -1125,4 +1165,18 @@ RSpec.describe GroupPolicy do it { is_expected.to be_disallowed(:admin_crm_contact) } it { is_expected.to be_disallowed(:admin_crm_organization) } end + + context 'when crm_enabled is false' do + let(:current_user) { owner } + + before_all do + group.crm_settings.enabled = false + group.crm_settings.save! + end + + it { is_expected.to be_disallowed(:read_crm_contact) } + it { is_expected.to be_disallowed(:read_crm_organization) } + it { is_expected.to be_disallowed(:admin_crm_contact) } + it { is_expected.to be_disallowed(:admin_crm_organization) } + end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 2953c198af6..38e4e18c894 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -61,7 +61,7 @@ RSpec.describe ProjectPolicy do end it 'does not include the issues permissions' do - expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident + expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task end it 'disables boards and lists permissions' do @@ -73,7 +73,7 @@ RSpec.describe ProjectPolicy do it 'does not include the issues permissions' do create(:jira_integration, project: project) - expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident + expect_disallowed :read_issue, :read_issue_iid, :create_issue, :update_issue, :admin_issue, :create_incident, :create_work_item, :create_task end end end diff --git a/spec/presenters/blob_presenter_spec.rb b/spec/presenters/blob_presenter_spec.rb index 8c0347b3c8d..3bf592ed2b9 100644 --- a/spec/presenters/blob_presenter_spec.rb +++ b/spec/presenters/blob_presenter_spec.rb @@ -53,6 +53,10 @@ RSpec.describe BlobPresenter do end end + describe '#archived?' do + it { expect(presenter.archived?).to eq(project.archived) } + end + describe '#pipeline_editor_path' do context 'when blob is .gitlab-ci.yml' do before do @@ -67,6 +71,22 @@ RSpec.describe BlobPresenter do end end + describe '#find_file_path' do + it { expect(presenter.find_file_path).to eq("/#{project.full_path}/-/find_file/HEAD/files/ruby/regex.rb") } + end + + describe '#blame_path' do + it { expect(presenter.blame_path).to eq("/#{project.full_path}/-/blame/HEAD/files/ruby/regex.rb") } + end + + describe '#history_path' do + it { expect(presenter.history_path).to eq("/#{project.full_path}/-/commits/HEAD/files/ruby/regex.rb") } + end + + describe '#permalink_path' do + it { expect(presenter.permalink_path).to eq("/#{project.full_path}/-/blob/#{project.repository.commit.sha}/files/ruby/regex.rb") } + end + describe '#code_owners' do it { expect(presenter.code_owners).to match_array([]) } end diff --git a/spec/presenters/label_presenter_spec.rb b/spec/presenters/label_presenter_spec.rb index bab0d9a1065..b4d36eaf340 100644 --- a/spec/presenters/label_presenter_spec.rb +++ b/spec/presenters/label_presenter_spec.rb @@ -10,6 +10,7 @@ RSpec.describe LabelPresenter do let(:label) { build_stubbed(:label, project: project).present(issuable_subject: project) } let(:group_label) { build_stubbed(:group_label, group: group).present(issuable_subject: project) } + let(:admin_label) { build_stubbed(:admin_label).present(issuable_subject: nil) } describe '#edit_path' do context 'with group label' do @@ -23,6 +24,12 @@ RSpec.describe LabelPresenter do it { is_expected.to eq(edit_project_label_path(project, label)) } end + + context 'with an admin label' do + subject { admin_label.edit_path } + + it { is_expected.to eq(edit_admin_label_path(admin_label)) } + end end describe '#destroy_path' do @@ -37,6 +44,12 @@ RSpec.describe LabelPresenter do it { is_expected.to eq(project_label_path(project, label)) } end + + context 'with an admin label' do + subject { admin_label.destroy_path } + + it { is_expected.to eq(admin_label_path(admin_label)) } + end end describe '#filter_path' do @@ -91,6 +104,12 @@ RSpec.describe LabelPresenter do it { is_expected.to eq(label.project.name) } end + + context 'with an admin label' do + subject { admin_label.subject_name } + + it { is_expected.to be_nil } + end end describe '#subject_full_name' do @@ -105,5 +124,11 @@ RSpec.describe LabelPresenter do it { is_expected.to eq(label.project.full_name) } end + + context 'with an admin label' do + subject { admin_label.subject_full_name } + + it { is_expected.to be_nil } + end end end diff --git a/spec/presenters/packages/conan/package_presenter_spec.rb b/spec/presenters/packages/conan/package_presenter_spec.rb index 6d82c5ef547..27ecf32b6f2 100644 --- a/spec/presenters/packages/conan/package_presenter_spec.rb +++ b/spec/presenters/packages/conan/package_presenter_spec.rb @@ -9,6 +9,7 @@ RSpec.describe ::Packages::Conan::PackagePresenter do let_it_be(:conan_package_reference) { '123456789'} let(:params) { { package_scope: :instance } } + let(:presenter) { described_class.new(package, user, project, params) } shared_examples 'no existing package' do context 'when package does not exist' do @@ -21,7 +22,7 @@ RSpec.describe ::Packages::Conan::PackagePresenter do shared_examples 'conan_file_metadatum is not found' do context 'when no conan_file_metadatum exists' do before do - package.package_files.each do |file| + package.installable_package_files.each do |file| file.conan_file_metadatum.delete file.reload end @@ -32,7 +33,7 @@ RSpec.describe ::Packages::Conan::PackagePresenter do end describe '#recipe_urls' do - subject { described_class.new(package, user, project, params).recipe_urls } + subject { presenter.recipe_urls } it_behaves_like 'no existing package' it_behaves_like 'conan_file_metadatum is not found' @@ -71,7 +72,9 @@ RSpec.describe ::Packages::Conan::PackagePresenter do end describe '#recipe_snapshot' do - subject { described_class.new(package, user, project).recipe_snapshot } + let(:params) { {} } + + subject { presenter.recipe_snapshot } it_behaves_like 'no existing package' it_behaves_like 'conan_file_metadatum is not found' @@ -180,12 +183,9 @@ RSpec.describe ::Packages::Conan::PackagePresenter do describe '#package_snapshot' do let(:reference) { conan_package_reference } + let(:params) { { conan_package_reference: reference } } - subject do - described_class.new( - package, user, project, conan_package_reference: reference - ).package_snapshot - end + subject { presenter.package_snapshot } it_behaves_like 'no existing package' it_behaves_like 'conan_file_metadatum is not found' @@ -208,4 +208,22 @@ RSpec.describe ::Packages::Conan::PackagePresenter do end end end + + # TODO when cleaning up packages_installable_package_files, consider removing this context and + # add a dummy package file pending destruction on L8 + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package) } + + subject { presenter.send(:package_files).to_a } + + it { is_expected.not_to include(package_file_pending_destruction) } + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it { is_expected.to include(package_file_pending_destruction) } + end + end end diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb index 3009f2bd56d..4e2645b27ff 100644 --- a/spec/presenters/packages/detail/package_presenter_spec.rb +++ b/spec/presenters/packages/detail/package_presenter_spec.rb @@ -6,12 +6,12 @@ RSpec.describe ::Packages::Detail::PackagePresenter do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, creator: user) } let_it_be(:package) { create(:npm_package, :with_build, project: project) } - let(:presenter) { described_class.new(package) } - let_it_be(:user_info) { { name: user.name, avatar_url: user.avatar_url } } + let(:presenter) { described_class.new(package) } + let!(:expected_package_files) do - package.package_files.map do |file| + package.installable_package_files.map do |file| { created_at: file.created_at, download_path: file.download_path, @@ -154,5 +154,21 @@ RSpec.describe ::Packages::Detail::PackagePresenter do expect(presenter.detail_view).to eq expected_package_details end end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package) } + + subject { presenter.detail_view[:package_files].map { |e| e[:id] } } + + it { is_expected.not_to include(package_file_pending_destruction.id) } + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it { is_expected.to include(package_file_pending_destruction.id) } + end + end end end diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb index 3b6dfcd20b8..2308f928c92 100644 --- a/spec/presenters/packages/npm/package_presenter_spec.rb +++ b/spec/presenters/packages/npm/package_presenter_spec.rb @@ -95,6 +95,27 @@ RSpec.describe ::Packages::Npm::PackagePresenter do end end end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package2, file_sha1: 'pending_destruction_sha1') } + + let(:shasums) { subject.values.map { |v| v.dig(:dist, :shasum) } } + + it 'does not return them' do + expect(shasums).not_to include(package_file_pending_destruction.file_sha1) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + package2.package_files.id_not_in(package_file_pending_destruction.id).delete_all + end + + it 'returns them' do + expect(shasums).to include(package_file_pending_destruction.file_sha1) + end + end + end end describe '#dist_tags' do diff --git a/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb b/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb index 8bb0694f39c..6e99b6bafec 100644 --- a/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb +++ b/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb @@ -24,6 +24,20 @@ RSpec.describe Packages::Nuget::PackageMetadataPresenter do subject { presenter.archive_url } it { is_expected.to end_with(expected_suffix) } + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package, file_name: 'pending_destruction.nupkg') } + + it { is_expected.not_to include('pending_destruction.nupkg') } + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it { is_expected.to include('pending_destruction.nupkg') } + end + end end describe '#catalog_entry' do diff --git a/spec/presenters/packages/nuget/search_results_presenter_spec.rb b/spec/presenters/packages/nuget/search_results_presenter_spec.rb index 39ec7251dfd..745914c6c43 100644 --- a/spec/presenters/packages/nuget/search_results_presenter_spec.rb +++ b/spec/presenters/packages/nuget/search_results_presenter_spec.rb @@ -9,9 +9,9 @@ RSpec.describe Packages::Nuget::SearchResultsPresenter do let_it_be(:tag2) { create(:packages_tag, package: package_a, name: 'tag2') } let_it_be(:packages_b) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageB') } let_it_be(:packages_c) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageC') } - let_it_be(:search_results) { OpenStruct.new(total_count: 3, results: [package_a, packages_b, packages_c].flatten) } - let_it_be(:presenter) { described_class.new(search_results) } + let(:search_results) { double('search_results', total_count: 3, results: [package_a, packages_b, packages_c].flatten) } + let(:presenter) { described_class.new(search_results) } let(:total_count) { presenter.total_count } let(:data) { presenter.data } diff --git a/spec/presenters/packages/pypi/package_presenter_spec.rb b/spec/presenters/packages/pypi/package_presenter_spec.rb index 25aa5c31034..8a23c0ec3cb 100644 --- a/spec/presenters/packages/pypi/package_presenter_spec.rb +++ b/spec/presenters/packages/pypi/package_presenter_spec.rb @@ -52,5 +52,21 @@ RSpec.describe ::Packages::Pypi::PackagePresenter do it_behaves_like 'pypi package presenter' end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package1, file_name: "package_file_pending_destruction") } + + let(:project_or_group) { project } + + it { is_expected.not_to include(package_file_pending_destruction.file_name)} + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it { is_expected.to include(package_file_pending_destruction.file_name)} + end + end end end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 27b777dec5f..e4a08bd56c8 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -554,7 +554,7 @@ RSpec.describe ProjectPresenter do expect(presenter.kubernetes_cluster_anchor_data).to have_attributes( is_link: false, label: a_string_including('Add Kubernetes cluster'), - link: presenter.new_project_cluster_path(project) + link: presenter.project_clusters_path(project) ) end end diff --git a/spec/presenters/projects/security/configuration_presenter_spec.rb b/spec/presenters/projects/security/configuration_presenter_spec.rb index 836753d0483..f9150179ae5 100644 --- a/spec/presenters/projects/security/configuration_presenter_spec.rb +++ b/spec/presenters/projects/security/configuration_presenter_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do before do stub_licensed_features(licensed_scan_types.to_h { |type| [type, true] }) - stub_feature_flags(corpus_management: false) + stub_feature_flags(corpus_management_ui: false) end describe '#to_html_data_attribute' do diff --git a/spec/presenters/service_hook_presenter_spec.rb b/spec/presenters/service_hook_presenter_spec.rb index 7d7b71f324a..25ded17fb34 100644 --- a/spec/presenters/service_hook_presenter_spec.rb +++ b/spec/presenters/service_hook_presenter_spec.rb @@ -12,7 +12,7 @@ RSpec.describe ServiceHookPresenter do subject { service_hook.present.logs_details_path(web_hook_log) } let(:expected_path) do - "/#{project.namespace.path}/#{project.name}/-/services/#{integration.to_param}/hook_logs/#{web_hook_log.id}" + "/#{project.namespace.path}/#{project.name}/-/integrations/#{integration.to_param}/hook_logs/#{web_hook_log.id}" end it { is_expected.to eq(expected_path) } @@ -22,7 +22,7 @@ RSpec.describe ServiceHookPresenter do subject { service_hook.present.logs_retry_path(web_hook_log) } let(:expected_path) do - "/#{project.namespace.path}/#{project.name}/-/services/#{integration.to_param}/hook_logs/#{web_hook_log.id}/retry" + "/#{project.namespace.path}/#{project.name}/-/integrations/#{integration.to_param}/hook_logs/#{web_hook_log.id}/retry" end it { is_expected.to eq(expected_path) } diff --git a/spec/presenters/web_hook_log_presenter_spec.rb b/spec/presenters/web_hook_log_presenter_spec.rb index aa9d1d8f545..5827f3378de 100644 --- a/spec/presenters/web_hook_log_presenter_spec.rb +++ b/spec/presenters/web_hook_log_presenter_spec.rb @@ -21,7 +21,7 @@ RSpec.describe WebHookLogPresenter do let(:web_hook) { create(:service_hook, integration: integration) } let(:integration) { create(:drone_ci_integration, project: project) } - it { is_expected.to eq(project_service_hook_log_path(project, integration, web_hook_log)) } + it { is_expected.to eq(project_integration_hook_log_path(project, integration, web_hook_log)) } end end @@ -41,7 +41,7 @@ RSpec.describe WebHookLogPresenter do let(:web_hook) { create(:service_hook, integration: integration) } let(:integration) { create(:drone_ci_integration, project: project) } - it { is_expected.to eq(retry_project_service_hook_log_path(project, integration, web_hook_log)) } + it { is_expected.to eq(retry_project_integration_hook_log_path(project, integration, web_hook_log)) } end end end diff --git a/spec/rake_helper.rb b/spec/rake_helper.rb index ca5b4d8337c..0386fef5134 100644 --- a/spec/rake_helper.rb +++ b/spec/rake_helper.rb @@ -11,7 +11,7 @@ RSpec.configure do |config| Rake::Task.define_task :environment end - config.after(:all) do + config.after(:all, type: :task) do delete_from_all_tables!(except: deletion_except_tables) end end diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb index 585fab33708..0db6acbc7b8 100644 --- a/spec/requests/api/ci/job_artifacts_spec.rb +++ b/spec/requests/api/ci/job_artifacts_spec.rb @@ -81,6 +81,71 @@ RSpec.describe API::Ci::JobArtifacts do end end + describe 'DELETE /projects/:id/artifacts' do + context 'when feature flag is disabled' do + before do + stub_feature_flags(bulk_expire_project_artifacts: false) + end + + it 'returns 404' do + delete api("/projects/#{project.id}/artifacts", api_user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is anonymous' do + let(:api_user) { nil } + + it 'does not execute Ci::JobArtifacts::DeleteProjectArtifactsService' do + expect(Ci::JobArtifacts::DeleteProjectArtifactsService) + .not_to receive(:new) + + delete api("/projects/#{project.id}/artifacts", api_user) + end + + it 'returns status 401 (unauthorized)' do + delete api("/projects/#{project.id}/artifacts", api_user) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'with developer' do + it 'does not execute Ci::JobArtifacts::DeleteProjectArtifactsService' do + expect(Ci::JobArtifacts::DeleteProjectArtifactsService) + .not_to receive(:new) + + delete api("/projects/#{project.id}/artifacts", api_user) + end + + it 'returns status 403 (forbidden)' do + delete api("/projects/#{project.id}/artifacts", api_user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'with authorized user' do + let(:maintainer) { create(:project_member, :maintainer, project: project).user } + let!(:api_user) { maintainer } + + it 'executes Ci::JobArtifacts::DeleteProjectArtifactsService' do + expect_next_instance_of(Ci::JobArtifacts::DeleteProjectArtifactsService, project: project) do |service| + expect(service).to receive(:execute).and_call_original + end + + delete api("/projects/#{project.id}/artifacts", api_user) + end + + it 'returns status 202 (accepted)' do + delete api("/projects/#{project.id}/artifacts", api_user) + + expect(response).to have_gitlab_http_status(:accepted) + end + end + end + describe 'GET /projects/:id/jobs/:job_id/artifacts/:artifact_path' do context 'when job has artifacts' do let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } diff --git a/spec/requests/api/ci/runner/runners_post_spec.rb b/spec/requests/api/ci/runner/runners_post_spec.rb index a51d8b458f8..530b601add9 100644 --- a/spec/requests/api/ci/runner/runners_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_post_spec.rb @@ -3,21 +3,6 @@ require 'spec_helper' RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do - include StubGitlabCalls - include RedisHelpers - include WorkhorseHelpers - - let(:registration_token) { 'abcdefg123456' } - - before do - stub_feature_flags(ci_enable_live_trace: true) - stub_feature_flags(runner_registration_control: false) - stub_gitlab_calls - stub_application_setting(runners_registration_token: registration_token) - stub_application_setting(valid_runner_registrars: ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES) - allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes) - end - describe '/api/v4/runners' do describe 'POST /api/v4/runners' do context 'when no token is provided' do @@ -30,380 +15,108 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do context 'when invalid token is provided' do it 'returns 403 error' do + allow_next_instance_of(::Ci::RegisterRunnerService) do |service| + allow(service).to receive(:execute).and_return(nil) + end + post api('/runners'), params: { token: 'invalid' } expect(response).to have_gitlab_http_status(:forbidden) end end - context 'when valid token is provided' do + context 'when valid parameters are provided' do def request - post api('/runners'), params: { token: token } - end - - context 'with a registration token' do - let(:token) { registration_token } - - it 'creates runner with default values' do - request - - runner = ::Ci::Runner.first - - expect(response).to have_gitlab_http_status(:created) - expect(json_response['id']).to eq(runner.id) - expect(json_response['token']).to eq(runner.token) - expect(runner.run_untagged).to be true - expect(runner.active).to be true - expect(runner.token).not_to eq(registration_token) - expect(runner).to be_instance_type - end - - it_behaves_like 'storing arguments in the application context for the API' do - subject { request } - - let(:expected_params) { { client_id: "runner/#{::Ci::Runner.first.id}" } } - end - - it_behaves_like 'not executing any extra queries for the application context' do - let(:subject_proc) { proc { request } } - end - end - - context 'when project token is used' do - let(:project) { create(:project) } - let(:token) { project.runners_token } - - it 'creates project runner' do - request - - expect(response).to have_gitlab_http_status(:created) - expect(project.runners.size).to eq(1) - runner = ::Ci::Runner.first - expect(runner.token).not_to eq(registration_token) - expect(runner.token).not_to eq(project.runners_token) - expect(runner).to be_project_type - end - - it_behaves_like 'storing arguments in the application context for the API' do - subject { request } - - let(:expected_params) { { project: project.full_path, client_id: "runner/#{::Ci::Runner.first.id}" } } - end - - it_behaves_like 'not executing any extra queries for the application context' do - let(:subject_proc) { proc { request } } - end - - context 'when it exceeds the application limits' do - before do - create(:ci_runner, runner_type: :project_type, projects: [project], contacted_at: 1.second.ago) - create(:plan_limits, :default_plan, ci_registered_project_runners: 1) - end - - it 'does not create runner' do - request - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to include('runner_projects.base' => ['Maximum number of ci registered project runners (1) exceeded']) - expect(project.runners.reload.size).to eq(1) - end - end - - context 'when abandoned runners cause application limits to not be exceeded' do - before do - create(:ci_runner, runner_type: :project_type, projects: [project], created_at: 14.months.ago, contacted_at: 13.months.ago) - create(:plan_limits, :default_plan, ci_registered_project_runners: 1) - end - - it 'creates runner' do - request - - expect(response).to have_gitlab_http_status(:created) - expect(json_response['message']).to be_nil - expect(project.runners.reload.size).to eq(2) - expect(project.runners.recent.size).to eq(1) - end - end - - context 'when valid runner registrars do not include project' do - before do - stub_application_setting(valid_runner_registrars: ['group']) - end - - context 'when feature flag is enabled' do - before do - stub_feature_flags(runner_registration_control: true) - end - - it 'returns 403 error' do - request - - expect(response).to have_gitlab_http_status(:forbidden) - end - end - - context 'when feature flag is disabled' do - it 'registers the runner' do - request - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.active).to be true - end - end - end - end - - context 'when group token is used' do - let(:group) { create(:group) } - let(:token) { group.runners_token } - - it 'creates a group runner' do - request - - expect(response).to have_gitlab_http_status(:created) - expect(group.runners.reload.size).to eq(1) - runner = ::Ci::Runner.first - expect(runner.token).not_to eq(registration_token) - expect(runner.token).not_to eq(group.runners_token) - expect(runner).to be_group_type - end - - it_behaves_like 'storing arguments in the application context for the API' do - subject { request } - - let(:expected_params) { { root_namespace: group.full_path_components.first, client_id: "runner/#{::Ci::Runner.first.id}" } } - end - - it_behaves_like 'not executing any extra queries for the application context' do - let(:subject_proc) { proc { request } } - end - - context 'when it exceeds the application limits' do - before do - create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 1.month.ago) - create(:plan_limits, :default_plan, ci_registered_group_runners: 1) - end - - it 'does not create runner' do - request - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to include('runner_namespaces.base' => ['Maximum number of ci registered group runners (1) exceeded']) - expect(group.runners.reload.size).to eq(1) - end - end - - context 'when abandoned runners cause application limits to not be exceeded' do - before do - create(:ci_runner, runner_type: :group_type, groups: [group], created_at: 4.months.ago, contacted_at: 3.months.ago) - create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 4.months.ago) - create(:plan_limits, :default_plan, ci_registered_group_runners: 1) - end - - it 'creates runner' do - request - - expect(response).to have_gitlab_http_status(:created) - expect(json_response['message']).to be_nil - expect(group.runners.reload.size).to eq(3) - expect(group.runners.recent.size).to eq(1) - end - end - - context 'when valid runner registrars do not include group' do - before do - stub_application_setting(valid_runner_registrars: ['project']) - end - - context 'when feature flag is enabled' do - before do - stub_feature_flags(runner_registration_control: true) - end - - it 'returns 403 error' do - request - - expect(response).to have_gitlab_http_status(:forbidden) - end - end - - context 'when feature flag is disabled' do - it 'registers the runner' do - request - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.active).to be true - end - end - end - end - end - - context 'when runner description is provided' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - description: 'server.hostname' - } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.description).to eq('server.hostname') - end - end - - context 'when runner tags are provided' do - it 'creates runner' do post api('/runners'), params: { - token: registration_token, - tag_list: 'tag1, tag2' - } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.tag_list.sort).to eq(%w(tag1 tag2)) - end - end - - context 'when option for running untagged jobs is provided' do - context 'when tags are provided' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - run_untagged: false, - tag_list: ['tag'] - } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.run_untagged).to be false - expect(::Ci::Runner.first.tag_list.sort).to eq(['tag']) - end - end - - context 'when tags are not provided' do - it 'returns 400 error' do - post api('/runners'), params: { - token: registration_token, - run_untagged: false - } - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to include( - 'tags_list' => ['can not be empty when runner is not allowed to pick untagged jobs']) + token: 'valid token', + description: 'server.hostname', + maintainer_note: 'Some maintainer notes', + run_untagged: false, + tag_list: 'tag1, tag2', + locked: true, + active: true, + access_level: 'ref_protected', + maximum_timeout: 9000 + } + end + + let_it_be(:new_runner) { create(:ci_runner) } + + before do + allow_next_instance_of(::Ci::RegisterRunnerService) do |service| + expected_params = { + description: 'server.hostname', + maintainer_note: 'Some maintainer notes', + run_untagged: false, + tag_list: %w(tag1 tag2), + locked: true, + active: true, + access_level: 'ref_protected', + maximum_timeout: 9000 + }.stringify_keys + + allow(service).to receive(:execute) + .once + .with('valid token', a_hash_including(expected_params)) + .and_return(new_runner) end end - end - context 'when option for locking Runner is provided' do it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - locked: true - } + request expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.locked).to be true + expect(json_response['id']).to eq(new_runner.id) + expect(json_response['token']).to eq(new_runner.token) end - end - context 'when option for activating a Runner is provided' do - context 'when active is set to true' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - active: true - } + it_behaves_like 'storing arguments in the application context for the API' do + subject { request } - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.active).to be true - end + let(:expected_params) { { client_id: "runner/#{new_runner.id}" } } end - context 'when active is set to false' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - active: false - } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.active).to be false - end + it_behaves_like 'not executing any extra queries for the application context' do + let(:subject_proc) { proc { request } } end end - context 'when access_level is provided for Runner' do - context 'when access_level is set to ref_protected' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - access_level: 'ref_protected' - } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.ref_protected?).to be true - end - end + context 'calling actual register service' do + include StubGitlabCalls - context 'when access_level is set to not_protected' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - access_level: 'not_protected' - } + let(:registration_token) { 'abcdefg123456' } - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.ref_protected?).to be false - end + before do + stub_gitlab_calls + stub_application_setting(runners_registration_token: registration_token) + allow_any_instance_of(::Ci::Runner).to receive(:cache_attributes) end - end - - context 'when maximum job timeout is specified' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - maximum_timeout: 9000 - } - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.maximum_timeout).to eq(9000) - end + %w(name version revision platform architecture).each do |param| + context "when info parameter '#{param}' info is present" do + let(:value) { "#{param}_value" } - context 'when maximum job timeout is empty' do - it 'creates runner' do - post api('/runners'), params: { - token: registration_token, - maximum_timeout: '' - } + it "updates provided Runner's parameter" do + post api('/runners'), params: { + token: registration_token, + info: { param => value } + } - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.maximum_timeout).to be_nil + expect(response).to have_gitlab_http_status(:created) + expect(::Ci::Runner.last.read_attribute(param.to_sym)).to eq(value) + end end end - end - %w(name version revision platform architecture).each do |param| - context "when info parameter '#{param}' info is present" do - let(:value) { "#{param}_value" } + it "sets the runner's ip_address" do + post api('/runners'), + params: { token: registration_token }, + headers: { 'X-Forwarded-For' => '123.111.123.111' } - it "updates provided Runner's parameter" do - post api('/runners'), params: { - token: registration_token, - info: { param => value } - } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.read_attribute(param.to_sym)).to eq(value) - end + expect(response).to have_gitlab_http_status(:created) + expect(::Ci::Runner.last.ip_address).to eq('123.111.123.111') end end - - it "sets the runner's ip_address" do - post api('/runners'), - params: { token: registration_token }, - headers: { 'X-Forwarded-For' => '123.111.123.111' } - - expect(response).to have_gitlab_http_status(:created) - expect(::Ci::Runner.first.ip_address).to eq('123.111.123.111') - end end end end diff --git a/spec/requests/api/ci/runners_spec.rb b/spec/requests/api/ci/runners_spec.rb index 6ca380a3cb9..305c0bd9df0 100644 --- a/spec/requests/api/ci/runners_spec.rb +++ b/spec/requests/api/ci/runners_spec.rb @@ -980,7 +980,7 @@ RSpec.describe API::Ci::Runners do end end - describe 'GET /groups/:id/runners' do + shared_context 'GET /groups/:id/runners' do context 'authorized user with maintainer privileges' do it 'returns all runners' do get api("/groups/#{group.id}/runners", user) @@ -1048,6 +1048,16 @@ RSpec.describe API::Ci::Runners do end end + it_behaves_like 'GET /groups/:id/runners' + + context 'when the FF ci_find_runners_by_ci_mirrors is disabled' do + before do + stub_feature_flags(ci_find_runners_by_ci_mirrors: false) + end + + it_behaves_like 'GET /groups/:id/runners' + end + describe 'POST /projects/:id/runners' do context 'authorized user' do let_it_be(:project_runner2) { create(:ci_runner, :project, projects: [project2]) } diff --git a/spec/requests/api/ci/triggers_spec.rb b/spec/requests/api/ci/triggers_spec.rb index d270a16d28d..a036a55f5f3 100644 --- a/spec/requests/api/ci/triggers_spec.rb +++ b/spec/requests/api/ci/triggers_spec.rb @@ -162,7 +162,7 @@ RSpec.describe API::Ci::Triggers do expect do post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{trigger_token}"), params: { ref: 'refs/heads/other-branch' }, - headers: { WebHookService::GITLAB_EVENT_HEADER => 'Pipeline Hook' } + headers: { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Pipeline Hook' } end.not_to change(Ci::Pipeline, :count) expect(response).to have_gitlab_http_status(:forbidden) diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 1e587480fd9..2bc642f8b14 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -1056,9 +1056,7 @@ RSpec.describe API::Commits do shared_examples_for 'ref with pipeline' do let!(:pipeline) do - project - .ci_pipelines - .create!(source: :push, ref: 'master', sha: commit.sha, protected: false) + create(:ci_empty_pipeline, project: project, status: :created, source: :push, ref: 'master', sha: commit.sha, protected: false) end it 'includes status as "created" and a last_pipeline object' do @@ -1090,9 +1088,7 @@ RSpec.describe API::Commits do shared_examples_for 'ref with unaccessible pipeline' do let!(:pipeline) do - project - .ci_pipelines - .create!(source: :push, ref: 'master', sha: commit.sha, protected: false) + create(:ci_empty_pipeline, project: project, status: :created, source: :push, ref: 'master', sha: commit.sha, protected: false) end it 'does not include last_pipeline' do diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb index 2d85d7b9583..1836233594d 100644 --- a/spec/requests/api/generic_packages_spec.rb +++ b/spec/requests/api/generic_packages_spec.rb @@ -574,6 +574,27 @@ RSpec.describe API::GenericPackages do end end + context 'with package status' do + where(:package_status, :expected_status) do + :default | :success + :hidden | :success + :error | :not_found + end + + with_them do + before do + project.add_developer(user) + package.update!(status: package_status) + end + + it "responds with #{params[:expected_status]}" do + download_file(personal_access_token_header) + + expect(response).to have_gitlab_http_status(expected_status) + end + end + end + context 'event tracking' do let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, user: user } } diff --git a/spec/requests/api/graphql/ci/config_spec.rb b/spec/requests/api/graphql/ci/config_spec.rb index 8ede6e1538c..755585f8e0e 100644 --- a/spec/requests/api/graphql/ci/config_spec.rb +++ b/spec/requests/api/graphql/ci/config_spec.rb @@ -20,6 +20,7 @@ RSpec.describe 'Query.ciConfig' do ciConfig(projectPath: "#{project.full_path}", content: "#{content}", dryRun: false) { status errors + warnings stages { nodes { name @@ -73,6 +74,7 @@ RSpec.describe 'Query.ciConfig' do expect(graphql_data['ciConfig']).to eq( "status" => "VALID", "errors" => [], + "warnings" => [], "stages" => { "nodes" => @@ -220,6 +222,21 @@ RSpec.describe 'Query.ciConfig' do ) end + context 'when using deprecated keywords' do + let_it_be(:content) do + YAML.dump( + rspec: { script: 'ls' }, + types: ['test'] + ) + end + + it 'returns a warning' do + post_graphql_query + + expect(graphql_data['ciConfig']['warnings']).to include('root `types` is deprecated in 9.0 and will be removed in 15.0.') + end + end + context 'when the config file includes other files' do let_it_be(:content) do YAML.dump( @@ -250,6 +267,7 @@ RSpec.describe 'Query.ciConfig' do expect(graphql_data['ciConfig']).to eq( "status" => "VALID", "errors" => [], + "warnings" => [], "stages" => { "nodes" => diff --git a/spec/requests/api/graphql/ci/jobs_spec.rb b/spec/requests/api/graphql/ci/jobs_spec.rb index 3a1df3525ef..b191b585d06 100644 --- a/spec/requests/api/graphql/ci/jobs_spec.rb +++ b/spec/requests/api/graphql/ci/jobs_spec.rb @@ -44,6 +44,10 @@ RSpec.describe 'Query.project.pipeline' do name jobs { nodes { + downstreamPipeline { + id + path + } name needs { nodes { #{all_graphql_fields_for('CiBuildNeed')} } @@ -131,6 +135,8 @@ RSpec.describe 'Query.project.pipeline' do end it 'does not generate N+1 queries', :request_store, :use_sql_query_cache do + create(:ci_bridge, name: 'bridge-1', pipeline: pipeline, downstream_pipeline: create(:ci_pipeline)) + post_graphql(query, current_user: user) control = ActiveRecord::QueryRecorder.new(skip_cached: false) do @@ -139,6 +145,8 @@ RSpec.describe 'Query.project.pipeline' do create(:ci_build, name: 'test-a', pipeline: pipeline) create(:ci_build, name: 'test-b', pipeline: pipeline) + create(:ci_bridge, name: 'bridge-2', pipeline: pipeline, downstream_pipeline: create(:ci_pipeline)) + create(:ci_bridge, name: 'bridge-3', pipeline: pipeline, downstream_pipeline: create(:ci_pipeline)) expect do post_graphql(query, current_user: user) diff --git a/spec/requests/api/graphql/ci/pipelines_spec.rb b/spec/requests/api/graphql/ci/pipelines_spec.rb index 95ddd0250e7..5ae68be46a2 100644 --- a/spec/requests/api/graphql/ci/pipelines_spec.rb +++ b/spec/requests/api/graphql/ci/pipelines_spec.rb @@ -12,6 +12,38 @@ RSpec.describe 'Query.project(fullPath).pipelines' do travel_to(Time.current) { example.run } end + describe 'sha' do + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + let(:pipelines_graphql_data) { graphql_data.dig(*%w[project pipelines nodes]).first } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + pipelines { + nodes { + fullSha: sha + shortSha: sha(format: SHORT) + alsoFull: sha(format: LONG) + } + } + } + } + ) + end + + it 'returns all formats of the SHA' do + post_graphql(query, current_user: user) + + expect(pipelines_graphql_data).to include( + 'fullSha' => eq(pipeline.sha), + 'alsoFull' => eq(pipeline.sha), + 'shortSha' => eq(pipeline.short_sha) + ) + end + end + describe 'duration fields' do let_it_be(:pipeline) do create(:ci_pipeline, project: project) @@ -251,6 +283,50 @@ RSpec.describe 'Query.project(fullPath).pipelines' do end end + describe 'warningMessages' do + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:warning_message) { create(:ci_pipeline_message, pipeline: pipeline, content: 'warning') } + + let(:pipelines_graphql_data) { graphql_data.dig(*%w[project pipelines nodes]).first } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + pipelines { + nodes { + warningMessages { + content + } + } + } + } + } + ) + end + + it 'returns pipeline warnings' do + post_graphql(query, current_user: user) + + expect(pipelines_graphql_data['warningMessages']).to contain_exactly( + a_hash_including('content' => 'warning') + ) + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + post_graphql(query, current_user: user) + end + + pipeline_2 = create(:ci_pipeline, project: project) + create(:ci_pipeline_message, pipeline: pipeline_2, content: 'warning') + + expect do + post_graphql(query, current_user: user) + end.not_to exceed_query_limit(control_count) + end + end + describe '.jobs(securityReportTypes)' do let_it_be(:query) do %( @@ -420,4 +496,36 @@ RSpec.describe 'Query.project(fullPath).pipelines' do end end end + + describe 'ref_path' do + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:pipeline_1) { create(:ci_pipeline, project: project, user: user, merge_request: merge_request) } + let_it_be(:pipeline_2) { create(:ci_pipeline, project: project, user: user, merge_request: merge_request) } + + let(:query) do + %( + query { + project(fullPath: "#{project.full_path}") { + pipelines { + nodes { + refPath + } + } + } + } + ) + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + post_graphql(query, current_user: user) + end + + create(:ci_pipeline, project: project, user: user, merge_request: merge_request) + + expect do + post_graphql(query, current_user: user) + end.not_to exceed_query_limit(control_count) + end + end end diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb index 98d3a3b1c51..8c919b48849 100644 --- a/spec/requests/api/graphql/ci/runner_spec.rb +++ b/spec/requests/api/graphql/ci/runner_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Query.runner(id)' do let_it_be(:active_instance_runner) do create(:ci_runner, :instance, description: 'Runner 1', contacted_at: 2.hours.ago, active: true, version: 'adfe156', revision: 'a', locked: true, ip_address: '127.0.0.1', maximum_timeout: 600, - access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true) + access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true, executor_type: :custom) end let_it_be(:inactive_instance_runner) do @@ -22,7 +22,7 @@ RSpec.describe 'Query.runner(id)' do let_it_be(:active_group_runner) do create(:ci_runner, :group, groups: [group], description: 'Group runner 1', contacted_at: 2.hours.ago, active: true, version: 'adfe156', revision: 'a', locked: true, ip_address: '127.0.0.1', maximum_timeout: 600, - access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true) + access_level: 0, tag_list: %w[tag1 tag2], run_untagged: true, executor_type: :shell) end def get_runner(id) @@ -57,6 +57,7 @@ RSpec.describe 'Query.runner(id)' do expect(runner_data).to match a_hash_including( 'id' => "gid://gitlab/Ci::Runner/#{runner.id}", 'description' => runner.description, + 'createdAt' => runner.created_at&.iso8601, 'contactedAt' => runner.contacted_at&.iso8601, 'version' => runner.version, 'shortSha' => runner.short_sha, @@ -69,6 +70,7 @@ RSpec.describe 'Query.runner(id)' do 'runUntagged' => runner.run_untagged, 'ipAddress' => runner.ip_address, 'runnerType' => runner.instance_type? ? 'INSTANCE_TYPE' : 'PROJECT_TYPE', + 'executorName' => runner.executor_type&.dasherize, 'jobCount' => 0, 'projectCount' => nil, 'adminUrl' => "http://localhost/admin/runners/#{runner.id}", diff --git a/spec/requests/api/graphql/group/group_members_spec.rb b/spec/requests/api/graphql/group/group_members_spec.rb index 31cb0393d7f..06afb5b9a49 100644 --- a/spec/requests/api/graphql/group/group_members_spec.rb +++ b/spec/requests/api/graphql/group/group_members_spec.rb @@ -56,12 +56,16 @@ RSpec.describe 'getting group members information' do context 'member relations' do let_it_be(:child_group) { create(:group, :public, parent: parent_group) } let_it_be(:grandchild_group) { create(:group, :public, parent: child_group) } + let_it_be(:invited_group) { create(:group, :public) } let_it_be(:child_user) { create(:user) } let_it_be(:grandchild_user) { create(:user) } + let_it_be(:invited_user) { create(:user) } + let_it_be(:group_link) { create(:group_group_link, shared_group: child_group, shared_with_group: invited_group) } before_all do child_group.add_guest(child_user) grandchild_group.add_guest(grandchild_user) + invited_group.add_guest(invited_user) end it 'returns direct members' do @@ -71,6 +75,13 @@ RSpec.describe 'getting group members information' do expect_array_response(child_user) end + it 'returns invited members plus inherited members' do + fetch_members(group: child_group, args: { relations: [:DIRECT, :INHERITED, :SHARED_FROM_GROUPS] }) + + expect(graphql_errors).to be_nil + expect_array_response(invited_user, user_1, user_2, child_user) + end + it 'returns direct and inherited members' do fetch_members(group: child_group, args: { relations: [:DIRECT, :INHERITED] }) diff --git a/spec/requests/api/graphql/group/work_item_types_spec.rb b/spec/requests/api/graphql/group/work_item_types_spec.rb new file mode 100644 index 00000000000..0667e09d1e9 --- /dev/null +++ b/spec/requests/api/graphql/group/work_item_types_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting a list of work item types for a group' do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:group) { create(:group, :private) } + + before_all do + group.add_developer(developer) + end + + let(:current_user) { developer } + + let(:fields) do + <<~GRAPHQL + workItemTypes{ + nodes { id name iconName } + } + GRAPHQL + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group.full_path }, + fields + ) + end + + context 'when user has access to the group' do + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns all default work item types' do + expect(graphql_data.dig('group', 'workItemTypes', 'nodes')).to match_array( + WorkItems::Type.default.map do |type| + hash_including('id' => type.to_global_id.to_s, 'name' => type.name, 'iconName' => type.icon_name) + end + ) + end + end + + context "when user doesn't have acces to the group" do + let(:current_user) { create(:user) } + + before do + post_graphql(query, current_user: current_user) + end + + it 'does not return the group' do + expect(graphql_data).to eq('group' => nil) + end + end + + context 'when the work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + post_graphql(query, current_user: current_user) + end + + it 'makes the workItemTypes field unavailable' do + expect(graphql_errors).to contain_exactly(hash_including("message" => "Field 'workItemTypes' doesn't exist on type 'Group'")) + end + end +end diff --git a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb index 2da69509ad6..79d687a2bdb 100644 --- a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb +++ b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb @@ -6,13 +6,18 @@ RSpec.describe 'Setting issues crm contacts' do include GraphqlHelpers let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, group: group) } - let_it_be(:contacts) { create_list(:contact, 4, group: group) } + let_it_be(:group) { create(:group, :crm_enabled) } + let_it_be(:subgroup) { create(:group, :crm_enabled, parent: group) } + let_it_be(:project) { create(:project, group: subgroup) } + let_it_be(:group_contacts) { create_list(:contact, 4, group: group) } + let_it_be(:subgroup_contacts) { create_list(:contact, 4, group: subgroup) } let(:issue) { create(:issue, project: project) } let(:operation_mode) { Types::MutationOperationModeEnum.default_mode } - let(:contact_ids) { [global_id_of(contacts[1]), global_id_of(contacts[2])] } + let(:contacts) { subgroup_contacts } + let(:initial_contacts) { contacts[0..1] } + let(:mutation_contacts) { contacts[1..2] } + let(:contact_ids) { contact_global_ids(mutation_contacts) } let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" } let(:mutation) do @@ -42,9 +47,47 @@ RSpec.describe 'Setting issues crm contacts' do graphql_mutation_response(:issue_set_crm_contacts) end + def contact_global_ids(contacts) + contacts.map { |contact| global_id_of(contact) } + end + before do - create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) - create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) + initial_contacts.each { |contact| create(:issue_customer_relations_contact, issue: issue, contact: contact) } + end + + shared_examples 'successful mutation' do + context 'replace' do + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array(contact_global_ids(mutation_contacts)) + end + end + + context 'append' do + let(:mutation_contacts) { [contacts[3]] } + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } + + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array(contact_global_ids(initial_contacts + mutation_contacts)) + end + end + + context 'remove' do + let(:mutation_contacts) { [contacts[0]] } + let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } + + it 'updates the issue with correct contacts' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) + .to match_array(contact_global_ids(initial_contacts - mutation_contacts)) + end + end end context 'when the user has no permission' do @@ -73,37 +116,14 @@ RSpec.describe 'Setting issues crm contacts' do end end - context 'replace' do - it 'updates the issue with correct contacts' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) - .to match_array([global_id_of(contacts[1]), global_id_of(contacts[2])]) - end - end + context 'with issue group contacts' do + let(:contacts) { subgroup_contacts } - context 'append' do - let(:contact_ids) { [global_id_of(contacts[3])] } - let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] } - - it 'updates the issue with correct contacts' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) - .to match_array([global_id_of(contacts[0]), global_id_of(contacts[1]), global_id_of(contacts[3])]) - end + it_behaves_like 'successful mutation' end - context 'remove' do - let(:contact_ids) { [global_id_of(contacts[0])] } - let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] } - - it 'updates the issue with correct contacts' do - post_graphql_mutation(mutation, current_user: user) - - expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id)) - .to match_array([global_id_of(contacts[1])]) - end + context 'with issue ancestor group contacts' do + it_behaves_like 'successful mutation' end context 'when the contact does not exist' do @@ -118,7 +138,7 @@ RSpec.describe 'Setting issues crm contacts' do end context 'when the contact belongs to a different group' do - let(:group2) { create(:group) } + let(:group2) { create(:group, :crm_enabled) } let(:contact) { create(:contact, group: group2) } let(:contact_ids) { [global_id_of(contact)] } @@ -158,4 +178,17 @@ RSpec.describe 'Setting issues crm contacts' do end end end + + context 'when crm_enabled is false' do + let(:issue) { create(:issue) } + let(:initial_contacts) { [] } + + it 'raises expected error' do + issue.project.add_reporter(user) + + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled')) + end + end end diff --git a/spec/requests/api/graphql/mutations/issues/set_escalation_status_spec.rb b/spec/requests/api/graphql/mutations/issues/set_escalation_status_spec.rb new file mode 100644 index 00000000000..0166871502b --- /dev/null +++ b/spec/requests/api/graphql/mutations/issues/set_escalation_status_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Setting the escalation status of an incident' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:incident, project: project) } + let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: issue) } + let_it_be(:user) { create(:user) } + + let(:status) { 'ACKNOWLEDGED' } + let(:input) { { project_path: project.full_path, iid: issue.iid.to_s, status: status } } + + let(:current_user) { user } + let(:mutation) do + graphql_mutation(:issue_set_escalation_status, input) do + <<~QL + clientMutationId + errors + issue { + iid + escalationStatus + } + QL + end + end + + let(:mutation_response) { graphql_mutation_response(:issue_set_escalation_status) } + + before_all do + project.add_developer(user) + end + + context 'when user does not have permission to edit the escalation status' do + let(:current_user) { create(:user) } + + before_all do + project.add_reporter(user) + end + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'with non-incident issue is provided' do + let_it_be(:issue) { create(:issue, project: project) } + + it_behaves_like 'a mutation that returns top-level errors', errors: ['Feature unavailable for provided issue'] + end + + context 'with feature disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it_behaves_like 'a mutation that returns top-level errors', errors: ['Feature unavailable for provided issue'] + end + + it 'sets given escalation_policy to the escalation status for the issue' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['errors']).to be_empty + expect(mutation_response['issue']['escalationStatus']).to eq(status) + expect(escalation_status.reload.status_name).to eq(:acknowledged) + end + + context 'when status argument is not given' do + let(:input) { {} } + + it_behaves_like 'a mutation that returns top-level errors' do + let(:match_errors) { contain_exactly(include('status (Expected value to not be null)')) } + end + end + + context 'when status argument is invalid' do + let(:status) { 'INVALID' } + + it_behaves_like 'an invalid argument to the mutation', argument_name: :status + end +end diff --git a/spec/requests/api/graphql/mutations/work_items/create_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_spec.rb new file mode 100644 index 00000000000..e7a0c7753fb --- /dev/null +++ b/spec/requests/api/graphql/mutations/work_items/create_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create a work item' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } } + + let(:input) do + { + 'title' => 'new title', + 'description' => 'new description', + 'workItemTypeId' => WorkItems::Type.default_by_type(:task).to_global_id.to_s + } + end + + let(:mutation) { graphql_mutation(:workItemCreate, input.merge('projectPath' => project.full_path)) } + + let(:mutation_response) { graphql_mutation_response(:work_item_create) } + + context 'the user is not allowed to create a work item' do + let(:current_user) { create(:user) } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to create a work item' do + let(:current_user) { developer } + + it 'creates the work item' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.to change(WorkItem, :count).by(1) + + created_work_item = WorkItem.last + + expect(response).to have_gitlab_http_status(:success) + expect(created_work_item.issue_type).to eq('task') + expect(created_work_item.work_item_type.base_type).to eq('task') + expect(mutation_response['workItem']).to include( + input.except('workItemTypeId').merge( + 'id' => created_work_item.to_global_id.to_s, + 'workItemType' => hash_including('name' => 'Task') + ) + ) + end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::WorkItems::Create } + end + + context 'when the work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: ["Field 'workItemCreate' doesn't exist on type 'Mutation'", "Variable $workItemCreateInput is declared by anonymous mutation but not used"] + end + end +end diff --git a/spec/requests/api/graphql/packages/package_spec.rb b/spec/requests/api/graphql/packages/package_spec.rb index a9019a7611a..2ff3bc7cc47 100644 --- a/spec/requests/api/graphql/packages/package_spec.rb +++ b/spec/requests/api/graphql/packages/package_spec.rb @@ -4,7 +4,9 @@ require 'spec_helper' RSpec.describe 'package details' do include GraphqlHelpers - let_it_be_with_reload(:project) { create(:project) } + let_it_be_with_reload(:group) { create(:group) } + let_it_be_with_reload(:project) { create(:project, group: group) } + let_it_be(:user) { create(:user) } let_it_be(:composer_package) { create(:composer_package, project: project) } let_it_be(:composer_json) { { name: 'name', type: 'type', license: 'license', version: 1 } } let_it_be(:composer_metadatum) do @@ -17,7 +19,6 @@ RSpec.describe 'package details' do let(:excluded) { %w[metadata apiFuzzingCiConfiguration pipeline packageFiles] } let(:metadata) { query_graphql_fragment('ComposerMetadata') } let(:package_files) {all_graphql_fields_for('PackageFile')} - let(:user) { project.owner } let(:package_global_id) { global_id_of(composer_package) } let(:package_details) { graphql_data_at(:package) } @@ -37,145 +38,198 @@ RSpec.describe 'package details' do subject { post_graphql(query, current_user: user) } - it_behaves_like 'a working graphql query' do + context 'with unauthorized user' do before do - subject + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) end - it 'matches the JSON schema' do - expect(package_details).to match_schema('graphql/packages/package_details') + it 'returns no packages' do + subject + + expect(graphql_data_at(:package)).to be_nil end end - context 'there are other versions of this package' do - let(:depth) { 3 } - let(:excluded) { %w[metadata project tags pipelines] } # to limit the query complexity - - let_it_be(:siblings) { create_list(:composer_package, 2, project: project, name: composer_package.name) } + context 'with authorized user' do + before do + project.add_developer(user) + end - it 'includes the sibling versions' do - subject + it_behaves_like 'a working graphql query' do + before do + subject + end - expect(graphql_data_at(:package, :versions, :nodes)).to match_array( - siblings.map { |p| a_hash_including('id' => global_id_of(p)) } - ) + it 'matches the JSON schema' do + expect(package_details).to match_schema('graphql/packages/package_details') + end end - context 'going deeper' do - let(:depth) { 6 } + context 'there are other versions of this package' do + let(:depth) { 3 } + let(:excluded) { %w[metadata project tags pipelines] } # to limit the query complexity - it 'does not create a cycle of versions' do + let_it_be(:siblings) { create_list(:composer_package, 2, project: project, name: composer_package.name) } + + it 'includes the sibling versions' do subject - expect(graphql_data_at(:package, :versions, :nodes, :version)).to be_present - expect(graphql_data_at(:package, :versions, :nodes, :versions, :nodes)).to eq [nil, nil] + expect(graphql_data_at(:package, :versions, :nodes)).to match_array( + siblings.map { |p| a_hash_including('id' => global_id_of(p)) } + ) end - end - end - context 'with a batched query' do - let_it_be(:conan_package) { create(:conan_package, project: project) } + context 'going deeper' do + let(:depth) { 6 } - let(:batch_query) do - <<~QUERY - { - a: package(id: "#{global_id_of(composer_package)}") { name } - b: package(id: "#{global_id_of(conan_package)}") { name } - } - QUERY + it 'does not create a cycle of versions' do + subject + + expect(graphql_data_at(:package, :versions, :nodes, :version)).to be_present + expect(graphql_data_at(:package, :versions, :nodes, :versions, :nodes)).to match_array [nil, nil] + end + end end - let(:a_packages_names) { graphql_data_at(:a, :packages, :nodes, :name) } + context 'with package files pending destruction' do + let_it_be(:package_file) { create(:package_file, package: composer_package) } + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: composer_package) } - it 'returns an error for the second package and data for the first' do - post_graphql(batch_query, current_user: user) + let(:package_file_ids) { graphql_data_at(:package, :package_files, :nodes).map { |node| node["id"] } } - expect(graphql_data_at(:a, :name)).to eq(composer_package.name) + it 'does not return them' do + subject - expect_graphql_errors_to_include [/Package details can be requested only for one package at a time/] - expect(graphql_data_at(:b)).to be(nil) - end - end + expect(package_file_ids).to contain_exactly(package_file.to_global_id.to_s) + end - context 'with unauthorized user' do - let_it_be(:user) { create(:user) } + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + it 'returns them' do + subject + + expect(package_file_ids).to contain_exactly(package_file_pending_destruction.to_global_id.to_s, package_file.to_global_id.to_s) + end + end end - it 'returns no packages' do - subject + context 'with a batched query' do + let_it_be(:conan_package) { create(:conan_package, project: project) } - expect(graphql_data_at(:package)).to be_nil - end - end + let(:batch_query) do + <<~QUERY + { + a: package(id: "#{global_id_of(composer_package)}") { name } + b: package(id: "#{global_id_of(conan_package)}") { name } + } + QUERY + end - context 'pipelines field', :aggregate_failures do - let(:pipelines) { create_list(:ci_pipeline, 6, project: project) } - let(:pipeline_gids) { pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse } + let(:a_packages_names) { graphql_data_at(:a, :packages, :nodes, :name) } - before do - composer_package.pipelines = pipelines - composer_package.save! - end + it 'returns an error for the second package and data for the first' do + post_graphql(batch_query, current_user: user) - def run_query(args) - pipelines_nodes = <<~QUERY - nodes { - id - } - pageInfo { - startCursor - endCursor - } - QUERY + expect(graphql_data_at(:a, :name)).to eq(composer_package.name) - query = graphql_query_for(:package, { id: package_global_id }, query_graphql_field("pipelines", args, pipelines_nodes)) - post_graphql(query, current_user: user) + expect_graphql_errors_to_include [/Package details can be requested only for one package at a time/] + expect(graphql_data_at(:b)).to be(nil) + end end - it 'loads the second page with pagination first correctly' do - run_query(first: 2) - pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') + context 'pipelines field', :aggregate_failures do + let(:pipelines) { create_list(:ci_pipeline, 6, project: project) } + let(:pipeline_gids) { pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse } - expect(pipeline_ids).to eq(pipeline_gids[0..1]) + before do + composer_package.pipelines = pipelines + composer_package.save! + end - cursor = graphql_data.dig('package', 'pipelines', 'pageInfo', 'endCursor') + def run_query(args) + pipelines_nodes = <<~QUERY + nodes { + id + } + pageInfo { + startCursor + endCursor + } + QUERY + + query = graphql_query_for(:package, { id: package_global_id }, query_graphql_field("pipelines", args, pipelines_nodes)) + post_graphql(query, current_user: user) + end - run_query(first: 2, after: cursor) + it 'loads the second page with pagination first correctly' do + run_query(first: 2) + pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') - pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') + expect(pipeline_ids).to eq(pipeline_gids[0..1]) - expect(pipeline_ids).to eq(pipeline_gids[2..3]) - end + cursor = graphql_data.dig('package', 'pipelines', 'pageInfo', 'endCursor') - it 'loads the second page with pagination last correctly' do - run_query(last: 2) - pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') + run_query(first: 2, after: cursor) - expect(pipeline_ids).to eq(pipeline_gids[4..5]) + pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') - cursor = graphql_data.dig('package', 'pipelines', 'pageInfo', 'startCursor') + expect(pipeline_ids).to eq(pipeline_gids[2..3]) + end - run_query(last: 2, before: cursor) + it 'loads the second page with pagination last correctly' do + run_query(last: 2) + pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') - pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') + expect(pipeline_ids).to eq(pipeline_gids[4..5]) - expect(pipeline_ids).to eq(pipeline_gids[2..3]) - end + cursor = graphql_data.dig('package', 'pipelines', 'pageInfo', 'startCursor') + + run_query(last: 2, before: cursor) + + pipeline_ids = graphql_data.dig('package', 'pipelines', 'nodes').pluck('id') - context 'with unauthorized user' do - let_it_be(:user) { create(:user) } + expect(pipeline_ids).to eq(pipeline_gids[2..3]) + end + end + context 'package managers paths' do before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + subject end - it 'returns no packages' do - run_query(first: 2) + it 'returns npm_url correctly' do + expect(graphql_data_at(:package, :npm_url)).to eq("http://localhost/api/v4/projects/#{project.id}/packages/npm") + end + + it 'returns maven_url correctly' do + expect(graphql_data_at(:package, :maven_url)).to eq("http://localhost/api/v4/projects/#{project.id}/packages/maven") + end + + it 'returns conan_url correctly' do + expect(graphql_data_at(:package, :conan_url)).to eq("http://localhost/api/v4/projects/#{project.id}/packages/conan") + end + + it 'returns nuget_url correctly' do + expect(graphql_data_at(:package, :nuget_url)).to eq("http://localhost/api/v4/projects/#{project.id}/packages/nuget/index.json") + end + + it 'returns pypi_url correctly' do + expect(graphql_data_at(:package, :pypi_url)).to eq("http://__token__:<your_personal_token>@localhost/api/v4/projects/#{project.id}/packages/pypi/simple") + end + + it 'returns pypi_setup_url correctly' do + expect(graphql_data_at(:package, :pypi_setup_url)).to eq("http://localhost/api/v4/projects/#{project.id}/packages/pypi") + end + + it 'returns composer_url correctly' do + expect(graphql_data_at(:package, :composer_url)).to eq("http://localhost/api/v4/group/#{group.id}/-/packages/composer/packages.json") + end - expect(graphql_data_at(:package)).to be_nil + it 'returns composer_config_repository_url correctly' do + expect(graphql_data_at(:package, :composer_config_repository_url)).to eq("localhost/#{group.id}") end end end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index b3e91afb5b3..f358ec3e53f 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -539,6 +539,43 @@ RSpec.describe 'getting an issue list for a project' do end end + context 'when fetching escalation status' do + let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: issue_a) } + + let(:statuses) { issue_data.to_h { |issue| [issue['iid'], issue['escalationStatus']] } } + let(:fields) do + <<~QUERY + edges { + node { + id + escalationStatus + } + } + QUERY + end + + before do + issue_a.update!(issue_type: Issue.issue_types[:incident]) + end + + it 'returns the escalation status values' do + post_graphql(query, current_user: current_user) + + statuses = issues_data.map { |issue| issue.dig('node', 'escalationStatus') } + + expect(statuses).to contain_exactly(escalation_status.status_name.upcase.to_s, nil) + end + + it 'avoids N+1 queries', :aggregate_failures do + base_count = ActiveRecord::QueryRecorder.new { run_with_clean_state(query, context: { current_user: current_user }) } + + new_incident = create(:incident, project: project) + create(:incident_management_issuable_escalation_status, issue: new_incident) + + expect { run_with_clean_state(query, context: { current_user: current_user }) }.not_to exceed_query_limit(base_count) + end + end + describe 'N+1 query checks' do let(:extra_iid_for_second_query) { issue_b.iid.to_s } let(:search_params) { { iids: [issue_a.iid.to_s] } } diff --git a/spec/requests/api/graphql/project/work_item_types_spec.rb b/spec/requests/api/graphql/project/work_item_types_spec.rb new file mode 100644 index 00000000000..2caaedda2a1 --- /dev/null +++ b/spec/requests/api/graphql/project/work_item_types_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting a list of work item types for a project' do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:project) { create(:project) } + + before_all do + project.add_developer(developer) + end + + let(:current_user) { developer } + + let(:fields) do + <<~GRAPHQL + workItemTypes{ + nodes { id name iconName } + } + GRAPHQL + end + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + fields + ) + end + + context 'when user has access to the project' do + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns all default work item types' do + expect(graphql_data.dig('project', 'workItemTypes', 'nodes')).to match_array( + WorkItems::Type.default.map do |type| + hash_including('id' => type.to_global_id.to_s, 'name' => type.name, 'iconName' => type.icon_name) + end + ) + end + end + + context "when user doesn't have access to the project" do + let(:current_user) { create(:user) } + + before do + post_graphql(query, current_user: current_user) + end + + it 'does not return the project' do + expect(graphql_data).to eq('project' => nil) + end + end + + context 'when the work_items feature flag is disabled' do + before do + stub_feature_flags(work_items: false) + post_graphql(query, current_user: current_user) + end + + it 'makes the workItemTypes field unavailable' do + expect(graphql_errors).to contain_exactly(hash_including("message" => "Field 'workItemTypes' doesn't exist on type 'Project'")) + end + end +end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index d226bb07c73..88c004345fc 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -801,6 +801,54 @@ RSpec.describe API::Groups do expect(json_response['shared_projects'].count).to eq(limit) end end + + context 'when a group is shared', :aggregate_failures do + let_it_be(:shared_group) { create(:group) } + let_it_be(:group2_sub) { create(:group, :private, parent: group2) } + let_it_be(:group_link_1) { create(:group_group_link, shared_group: shared_group, shared_with_group: group1) } + let_it_be(:group_link_2) { create(:group_group_link, shared_group: shared_group, shared_with_group: group2_sub) } + + subject(:shared_with_groups) { json_response['shared_with_groups'].map { _1['group_id']} } + + context 'when authenticated as admin' do + it 'returns all groups that share the group' do + get api("/groups/#{shared_group.id}", admin) + + expect(response).to have_gitlab_http_status(:ok) + expect(shared_with_groups).to contain_exactly(group_link_1.shared_with_group_id, group_link_2.shared_with_group_id) + end + end + + context 'when unauthenticated' do + it 'returns only public groups that share the group' do + get api("/groups/#{shared_group.id}") + + expect(response).to have_gitlab_http_status(:ok) + expect(shared_with_groups).to contain_exactly(group_link_1.shared_with_group_id) + end + end + + context 'when authenticated as a member of a parent group that has shared the group' do + it 'returns private group if direct member' do + group2_sub.add_guest(user3) + + get api("/groups/#{shared_group.id}", user3) + + expect(response).to have_gitlab_http_status(:ok) + expect(shared_with_groups).to contain_exactly(group_link_1.shared_with_group_id, group_link_2.shared_with_group_id) + end + + it 'returns private group if inherited member' do + inherited_guest_member = create(:user) + group2.add_guest(inherited_guest_member) + + get api("/groups/#{shared_group.id}", inherited_guest_member) + + expect(response).to have_gitlab_http_status(:ok) + expect(shared_with_groups).to contain_exactly(group_link_1.shared_with_group_id, group_link_2.shared_with_group_id) + end + end + end end describe 'PUT /groups/:id' do diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb index 649647804c0..033c80a5696 100644 --- a/spec/requests/api/integrations_spec.rb +++ b/spec/requests/api/integrations_spec.rb @@ -55,8 +55,10 @@ RSpec.describe API::Integrations do current_integration = project.integrations.first events = current_integration.event_names.empty? ? ["foo"].freeze : current_integration.event_names query_strings = [] - events.each do |event| - query_strings << "#{event}=#{!current_integration[event]}" + events.map(&:to_sym).each do |event| + event_value = !current_integration[event] + query_strings << "#{event}=#{event_value}" + integration_attrs[event] = event_value if integration_attrs[event].present? end query_strings = query_strings.join('&') diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index 0a71eb43f81..9aa8aaafc68 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -372,7 +372,38 @@ RSpec.describe API::Internal::Base do end end - describe "POST /internal/allowed", :clean_gitlab_redis_shared_state do + describe "POST /internal/allowed", :clean_gitlab_redis_shared_state, :clean_gitlab_redis_rate_limiting do + shared_examples 'rate limited request' do + let(:action) { 'git-upload-pack' } + let(:actor) { key } + + it 'is throttled by rate limiter' do + allow(::Gitlab::ApplicationRateLimiter).to receive(:threshold).and_return(1) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:gitlab_shell_operation, scope: [action, project.full_path, actor]).twice.and_call_original + + request + + expect(response).to have_gitlab_http_status(:ok) + + request + + expect(response).to have_gitlab_http_status(:too_many_requests) + expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.') + end + + context 'when rate_limit_gitlab_shell feature flag is disabled' do + before do + stub_feature_flags(rate_limit_gitlab_shell: false) + end + + it 'is not throttled by rate limiter' do + expect(::Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) + + subject + end + end + end + context "access granted" do let(:env) { {} } @@ -530,6 +561,32 @@ RSpec.describe API::Internal::Base do expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-mep-mep' => 'true') expect(user.reload.last_activity_on).to eql(Date.today) end + + it_behaves_like 'rate limited request' do + def request + pull(key, project) + end + end + + context 'when user_id is passed' do + it_behaves_like 'rate limited request' do + let(:actor) { user } + + def request + post( + api("/internal/allowed"), + params: { + user_id: user.id, + project: full_path_for(project), + gl_repository: gl_repository_for(project), + action: 'git-upload-pack', + secret_token: secret_token, + protocol: 'ssh' + } + ) + end + end + end end context "with a feature flag enabled for a project" do @@ -576,6 +633,14 @@ RSpec.describe API::Internal::Base do expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage)) expect(user.reload.last_activity_on).to be_nil end + + it_behaves_like 'rate limited request' do + let(:action) { 'git-receive-pack' } + + def request + push(key, project) + end + end end context 'when receive_max_input_size has been updated' do @@ -838,6 +903,14 @@ RSpec.describe API::Internal::Base do 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 + + it_behaves_like 'rate limited request' do + let(:action) { 'git-upload-archive' } + + def request + archive(key, project) + end + end end context "not added to project" do diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index 245e4e6ba15..59d185fe6c8 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -53,7 +53,9 @@ RSpec.describe API::Internal::Kubernetes do shared_examples 'agent token tracking' do it 'tracks token usage' do - expect { response }.to change { agent_token.reload.read_attribute(:last_used_at) } + expect do + send_request(headers: { 'Authorization' => "Bearer #{agent_token.token}" }) + end.to change { agent_token.reload.read_attribute(:last_used_at) } end end @@ -149,7 +151,7 @@ RSpec.describe API::Internal::Kubernetes do let(:agent) { agent_token.agent } let(:project) { agent.project } - shared_examples 'agent token tracking' + include_examples 'agent token tracking' it 'returns expected data', :aggregate_failures do send_request(headers: { 'Authorization' => "Bearer #{agent_token.token}" }) diff --git a/spec/requests/api/internal/mail_room_spec.rb b/spec/requests/api/internal/mail_room_spec.rb new file mode 100644 index 00000000000..f3ca3708c0c --- /dev/null +++ b/spec/requests/api/internal/mail_room_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Internal::MailRoom do + let(:base_configs) do + { + enabled: true, + address: 'address@example.com', + port: 143, + ssl: false, + start_tls: false, + mailbox: 'inbox', + idle_timeout: 60, + log_path: Rails.root.join('log', 'mail_room_json.log').to_s, + expunge_deleted: false + } + end + + let(:enabled_configs) do + { + incoming_email: base_configs.merge( + secure_file: Rails.root.join('tmp', 'tests', '.incoming_email_secret').to_s + ), + service_desk_email: base_configs.merge( + secure_file: Rails.root.join('tmp', 'tests', '.service_desk_email').to_s + ) + } + end + + let(:auth_payload) { { 'iss' => Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_JWT_ISSUER, 'iat' => (Time.now - 10.seconds).to_i } } + + let(:incoming_email_secret) { 'incoming_email_secret' } + let(:service_desk_email_secret) { 'service_desk_email_secret' } + + let(:email_content) { fixture_file("emails/commands_in_reply.eml") } + + before do + allow(Gitlab::MailRoom::Authenticator).to receive(:secret).with(:incoming_email).and_return(incoming_email_secret) + allow(Gitlab::MailRoom::Authenticator).to receive(:secret).with(:service_desk_email).and_return(service_desk_email_secret) + allow(Gitlab::MailRoom).to receive(:enabled_configs).and_return(enabled_configs) + end + + around do |example| + freeze_time do + example.run + end + end + + describe "POST /internal/mail_room/*mailbox_type" do + context 'handle incoming_email successfully' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'schedules a EmailReceiverWorker job with raw email content' do + Sidekiq::Testing.fake! do + expect do + post api("/internal/mail_room/incoming_email"), headers: auth_headers, params: email_content + end.to change { EmailReceiverWorker.jobs.size }.by(1) + end + + expect(response).to have_gitlab_http_status(:ok) + + job = EmailReceiverWorker.jobs.last + expect(job).to match a_hash_including('args' => [email_content]) + end + end + + context 'handle service_desk_email successfully' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'schedules a ServiceDeskEmailReceiverWorker job with raw email content' do + Sidekiq::Testing.fake! do + expect do + post api("/internal/mail_room/service_desk_email"), headers: auth_headers, params: email_content + end.to change { ServiceDeskEmailReceiverWorker.jobs.size }.by(1) + end + + expect(response).to have_gitlab_http_status(:ok) + + job = ServiceDeskEmailReceiverWorker.jobs.last + expect(job).to match a_hash_including('args' => [email_content]) + end + end + + context 'email content exceeds limit' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + before do + allow(EmailReceiverWorker).to receive(:perform_async).and_raise( + Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError.new(EmailReceiverWorker, email_content.bytesize, email_content.bytesize - 1) + ) + end + + it 'responds with 400 bad request and replies with a failure message' do + perform_enqueued_jobs do + Sidekiq::Testing.fake! do + expect do + post api("/internal/mail_room/incoming_email"), headers: auth_headers, params: email_content + end.not_to change { EmailReceiverWorker.jobs.size } + end + end + + expect(response).to have_gitlab_http_status(:bad_request) + expect(Gitlab::Json.parse(response.body)).to match a_hash_including( + "success" => false, + "message" => "We couldn't process your email because it is too large. Please create your issue or comment through the web interface." + ) + + email = ActionMailer::Base.deliveries.last + expect(email).not_to be_nil + expect(email.to).to match_array(["jake@adventuretime.ooo"]) + expect(email.subject).to include("Rejected") + expect(email.body.parts.last.to_s).to include("We couldn't process your email") + end + end + + context 'not authenticated' do + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/incoming_email") + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'wrong token authentication' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, 'wrongsecret', 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/incoming_email"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'wrong mailbox type authentication' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/incoming_email"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'not supported mailbox type' do + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/invalid_mailbox_type"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'not enabled mailbox type' do + let(:enabled_configs) do + { + incoming_email: base_configs.merge( + secure_file: Rails.root.join('tmp', 'tests', '.incoming_email_secret').to_s + ) + } + end + + let(:auth_headers) do + jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256') + { Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token } + end + + it 'responds with 401 Unauthorized' do + post api("/internal/mail_room/service_desk_email"), headers: auth_headers + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index 0e83b964121..7c1e731a99a 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -121,8 +121,8 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:ok) expect(json_response).to be_an Hash expect(json_response['status']).to eq('valid') - expect(json_response['warnings']).to eq([]) - expect(json_response['errors']).to eq([]) + expect(json_response['warnings']).to match_array([]) + expect(json_response['errors']).to match_array([]) end it 'outputs expanded yaml content' do @@ -149,7 +149,20 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:ok) expect(json_response['status']).to eq('valid') expect(json_response['warnings']).not_to be_empty - expect(json_response['errors']).to eq([]) + expect(json_response['errors']).to match_array([]) + end + end + + context 'with valid .gitlab-ci.yaml using deprecated keywords' do + let(:yaml_content) { { job: { script: 'ls' }, types: ['test'] }.to_yaml } + + it 'passes validation but returns warnings' do + post api('/ci/lint', api_user), params: { content: yaml_content } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['status']).to eq('valid') + expect(json_response['warnings']).not_to be_empty + expect(json_response['errors']).to match_array([]) end end diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 5a682ee8532..bc325aad823 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -425,7 +425,7 @@ RSpec.describe API::MavenPackages do context 'internal project' do before do - group.group_member(user).destroy! + group.member(user).destroy! project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 7c147419354..a751f785913 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1269,6 +1269,7 @@ RSpec.describe API::MergeRequests do get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(json_response).to include('merged_by', + 'merge_user', 'merged_at', 'closed_by', 'closed_at', @@ -1279,9 +1280,10 @@ RSpec.describe API::MergeRequests do end it 'returns correct values' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.reload.iid}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(json_response['merged_by']['id']).to eq(merge_request.metrics.merged_by_id) + expect(json_response['merge_user']['id']).to eq(merge_request.metrics.merged_by_id) expect(Time.parse(json_response['merged_at'])).to be_like_time(merge_request.metrics.merged_at) expect(json_response['closed_by']['id']).to eq(merge_request.metrics.latest_closed_by_id) expect(Time.parse(json_response['closed_at'])).to be_like_time(merge_request.metrics.latest_closed_at) @@ -1292,6 +1294,32 @@ RSpec.describe API::MergeRequests do end end + context 'merge_user' do + context 'when MR is set to MWPS' do + let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds, source_project: project, target_project: project) } + + it 'returns user who set MWPS' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['merge_user']['id']).to eq(user.id) + end + + context 'when MR is already merged' do + before do + merge_request.metrics.update!(merged_by: user2) + end + + it 'returns user who actually merged' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['merge_user']['id']).to eq(user2.id) + end + end + end + end + context 'head_pipeline' do let(:project) { create(:project, :repository) } let(:merge_request) { create(:merge_request, :simple, author: user, source_project: project, source_branch: 'markdown', title: "Test") } @@ -3278,9 +3306,10 @@ RSpec.describe API::MergeRequests do context 'when skip_ci parameter is set' do it 'enqueues a rebase of the merge request with skip_ci flag set' do - allow(RebaseWorker).to receive(:with_status).and_return(RebaseWorker) + with_status = RebaseWorker.with_status - expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id, true).and_call_original + expect(RebaseWorker).to receive(:with_status).and_return(with_status) + expect(with_status).to receive(:perform_async).with(merge_request.id, user.id, true).and_call_original Sidekiq::Testing.fake! do expect do diff --git a/spec/requests/api/package_files_spec.rb b/spec/requests/api/package_files_spec.rb index eb1f04d193e..7a6b1599154 100644 --- a/spec/requests/api/package_files_spec.rb +++ b/spec/requests/api/package_files_spec.rb @@ -76,6 +76,30 @@ RSpec.describe API::PackageFiles do end end end + + context 'with package files pending destruction' do + let!(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package) } + + let(:package_file_ids) { json_response.map { |e| e['id'] } } + + it 'does not return them' do + get api(url, user) + + expect(package_file_ids).not_to include(package_file_pending_destruction.id) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + get api(url, user) + + expect(package_file_ids).to include(package_file_pending_destruction.id) + end + end + end end end @@ -149,6 +173,32 @@ RSpec.describe API::PackageFiles do expect(response).to have_gitlab_http_status(:not_found) end end + + context 'with package file pending destruction' do + let!(:package_file_id) { create(:package_file, :pending_destruction, package: package).id } + + before do + project.add_maintainer(user) + end + + it 'can not be accessed', :aggregate_failures do + expect { api_request }.not_to change { package.package_files.count } + + expect(response).to have_gitlab_http_status(:not_found) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'can be accessed', :aggregate_failures do + expect { api_request }.to change { package.package_files.count }.by(-1) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + end end end end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 8406ded85d8..bf41a808219 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -3704,6 +3704,46 @@ RSpec.describe API::Projects do expect { subject }.to change { project.reload.keep_latest_artifact }.to(true) end end + + context 'attribute mr_default_target_self' do + let_it_be(:source_project) { create(:project, :public) } + + let(:forked_project) { fork_project(source_project, user) } + + it 'is by default set to false' do + expect(source_project.mr_default_target_self).to be false + expect(forked_project.mr_default_target_self).to be false + end + + describe 'for a non-forked project' do + before_all do + source_project.add_maintainer(user) + end + + it 'is not exposed' do + get api("/projects/#{source_project.id}", user) + + expect(json_response).not_to include('mr_default_target_self') + end + + it 'is not possible to update' do + put api("/projects/#{source_project.id}", user), params: { mr_default_target_self: true } + + source_project.reload + expect(source_project.mr_default_target_self).to be false + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + describe 'for a forked project' do + it 'updates to true' do + put api("/projects/#{forked_project.id}", user), params: { mr_default_target_self: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['mr_default_target_self']).to eq(true) + end + end + end end describe 'POST /projects/:id/archive' do @@ -4213,7 +4253,13 @@ RSpec.describe API::Projects do end it 'accepts custom parameters for the target project' do - post api("/projects/#{project.id}/fork", user2), params: { name: 'My Random Project', description: 'A description', visibility: 'private' } + post api("/projects/#{project.id}/fork", user2), + params: { + name: 'My Random Project', + description: 'A description', + visibility: 'private', + mr_default_target_self: true + } expect(response).to have_gitlab_http_status(:created) expect(json_response['name']).to eq('My Random Project') @@ -4224,6 +4270,7 @@ RSpec.describe API::Projects do expect(json_response['description']).to eq('A description') expect(json_response['visibility']).to eq('private') expect(json_response['import_status']).to eq('scheduled') + expect(json_response['mr_default_target_self']).to eq(true) expect(json_response).to include("import_error") end diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb index 23061ab4bf0..7e3e682767f 100644 --- a/spec/requests/api/resource_access_tokens_spec.rb +++ b/spec/requests/api/resource_access_tokens_spec.rb @@ -3,25 +3,27 @@ require "spec_helper" RSpec.describe API::ResourceAccessTokens do - context "when the resource is a project" do - let_it_be(:project) { create(:project) } - let_it_be(:other_project) { create(:project) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let_it_be(:user_non_priviledged) { create(:user) } - describe "GET projects/:id/access_tokens" do - subject(:get_tokens) { get api("/projects/#{project_id}/access_tokens", user) } + shared_examples 'resource access token API' do |source_type| + context "GET #{source_type}s/:id/access_tokens" do + subject(:get_tokens) { get api("/#{source_type}s/#{resource_id}/access_tokens", user) } - context "when the user has maintainer permissions" do + context "when the user has valid permissions" do let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:access_tokens) { create_list(:personal_access_token, 3, user: project_bot) } - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } before do - project.add_maintainer(user) - project.add_maintainer(project_bot) + if source_type == 'project' + resource.add_maintainer(project_bot) + else + resource.add_owner(project_bot) + end end - it "gets a list of access tokens for the specified project" do + it "gets a list of access tokens for the specified #{source_type}" do get_tokens token_ids = json_response.map { |token| token['id'] } @@ -38,16 +40,22 @@ RSpec.describe API::ResourceAccessTokens do expect(api_get_token["name"]).to eq(token.name) expect(api_get_token["scopes"]).to eq(token.scopes) - expect(api_get_token["access_level"]).to eq(project.team.max_member_access(token.user.id)) + + if source_type == 'project' + expect(api_get_token["access_level"]).to eq(resource.team.max_member_access(token.user.id)) + else + expect(api_get_token["access_level"]).to eq(resource.max_member_access_for_user(token.user)) + end + expect(api_get_token["expires_at"]).to eq(token.expires_at.to_date.iso8601) expect(api_get_token).not_to have_key('token') end - context "when using a project access token to GET other project access tokens" do + context "when using a #{source_type} access token to GET other #{source_type} access tokens" do let_it_be(:token) { access_tokens.first } - it "gets a list of access tokens for the specified project" do - get api("/projects/#{project_id}/access_tokens", personal_access_token: token) + it "gets a list of access tokens for the specified #{source_type}" do + get api("/#{source_type}s/#{resource_id}/access_tokens", personal_access_token: token) token_ids = json_response.map { |token| token['id'] } @@ -56,16 +64,15 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when tokens belong to a different project" do + context "when tokens belong to a different #{source_type}" do let_it_be(:bot) { create(:user, :project_bot) } let_it_be(:token) { create(:personal_access_token, user: bot) } before do - other_project.add_maintainer(bot) - other_project.add_maintainer(user) + other_resource.add_maintainer(bot) end - it "does not return tokens from a different project" do + it "does not return tokens from a different #{source_type}" do get_tokens token_ids = json_response.map { |token| token['id'] } @@ -74,12 +81,8 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when the project has no access tokens" do - let(:project_id) { other_project.id } - - before do - other_project.add_maintainer(user) - end + context "when the #{source_type} has no access tokens" do + let(:resource_id) { other_resource.id } it 'returns an empty array' do get_tokens @@ -89,8 +92,8 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when trying to get the tokens of a different project" do - let_it_be(:project_id) { other_project.id } + context "when trying to get the tokens of a different #{source_type}" do + let_it_be(:resource_id) { unknown_resource.id } it "returns 404" do get_tokens @@ -99,8 +102,8 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when the project does not exist" do - let(:project_id) { non_existing_record_id } + context "when the #{source_type} does not exist" do + let(:resource_id) { non_existing_record_id } it "returns 404" do get_tokens @@ -111,13 +114,13 @@ RSpec.describe API::ResourceAccessTokens do end context "when the user does not have valid permissions" do + let_it_be(:user) { user_non_priviledged } let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:access_tokens) { create_list(:personal_access_token, 3, user: project_bot) } - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } before do - project.add_developer(user) - project.add_maintainer(project_bot) + resource.add_maintainer(project_bot) end it "returns 401" do @@ -128,40 +131,36 @@ RSpec.describe API::ResourceAccessTokens do end end - describe "DELETE projects/:id/access_tokens/:token_id", :sidekiq_inline do - subject(:delete_token) { delete api("/projects/#{project_id}/access_tokens/#{token_id}", user) } + context "DELETE #{source_type}s/:id/access_tokens/:token_id", :sidekiq_inline do + subject(:delete_token) { delete api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) } let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:token) { create(:personal_access_token, user: project_bot) } - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } let_it_be(:token_id) { token.id } before do - project.add_maintainer(project_bot) + resource.add_maintainer(project_bot) end - context "when the user has maintainer permissions" do - before do - project.add_maintainer(user) - end - - it "deletes the project access token from the project" do + context "when the user has valid permissions" do + it "deletes the #{source_type} access token from the #{source_type}" do delete_token expect(response).to have_gitlab_http_status(:no_content) expect(User.exists?(project_bot.id)).to be_falsy end - context "when using project access token to DELETE other project access token" do + context "when using #{source_type} access token to DELETE other #{source_type} access token" do let_it_be(:other_project_bot) { create(:user, :project_bot) } let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) } let_it_be(:token_id) { other_token.id } before do - project.add_maintainer(other_project_bot) + resource.add_maintainer(other_project_bot) end - it "deletes the project access token from the project" do + it "deletes the #{source_type} access token from the #{source_type}" do delete_token expect(response).to have_gitlab_http_status(:no_content) @@ -169,37 +168,31 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when attempting to delete a non-existent project access token" do + context "when attempting to delete a non-existent #{source_type} access token" do let_it_be(:token_id) { non_existing_record_id } it "does not delete the token, and returns 404" do delete_token expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to include("Could not find project access token with token_id: #{token_id}") + expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}") end end - context "when attempting to delete a token that does not belong to the specified project" do - let_it_be(:project_id) { other_project.id } - - before do - other_project.add_maintainer(user) - end + context "when attempting to delete a token that does not belong to the specified #{source_type}" do + let_it_be(:resource_id) { other_resource.id } it "does not delete the token, and returns 404" do delete_token expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to include("Could not find project access token with token_id: #{token_id}") + expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}") end end end context "when the user does not have valid permissions" do - before do - project.add_developer(user) - end + let_it_be(:user) { user_non_priviledged } it "does not delete the token, and returns 400", :aggregate_failures do delete_token @@ -211,23 +204,19 @@ RSpec.describe API::ResourceAccessTokens do end end - describe "POST projects/:id/access_tokens" do + context "POST #{source_type}s/:id/access_tokens" do let(:params) { { name: "test", scopes: ["api"], expires_at: expires_at, access_level: access_level } } let(:expires_at) { 1.month.from_now } let(:access_level) { 20 } - subject(:create_token) { post api("/projects/#{project_id}/access_tokens", user), params: params } + subject(:create_token) { post api("/#{source_type}s/#{resource_id}/access_tokens", user), params: params } - context "when the user has maintainer permissions" do - let_it_be(:project_id) { project.id } - - before do - project.add_maintainer(user) - end + context "when the user has valid permissions" do + let_it_be(:resource_id) { resource.id } context "with valid params" do context "with full params" do - it "creates a project access token with the params", :aggregate_failures do + it "creates a #{source_type} access token with the params", :aggregate_failures do create_token expect(response).to have_gitlab_http_status(:created) @@ -242,7 +231,7 @@ RSpec.describe API::ResourceAccessTokens do context "when 'expires_at' is not set" do let(:expires_at) { nil } - it "creates a project access token with the params", :aggregate_failures do + it "creates a #{source_type} access token with the params", :aggregate_failures do create_token expect(response).to have_gitlab_http_status(:created) @@ -255,7 +244,7 @@ RSpec.describe API::ResourceAccessTokens do context "when 'access_level' is not set" do let(:access_level) { nil } - it 'creates a project access token with the default access level', :aggregate_failures do + it "creates a #{source_type} access token with the default access level", :aggregate_failures do create_token expect(response).to have_gitlab_http_status(:created) @@ -272,7 +261,7 @@ RSpec.describe API::ResourceAccessTokens do context "when missing the 'name' param" do let_it_be(:params) { { scopes: ["api"], expires_at: 5.days.from_now } } - it "does not create a project access token without 'name'" do + it "does not create a #{source_type} access token without 'name'" do create_token expect(response).to have_gitlab_http_status(:bad_request) @@ -283,7 +272,7 @@ RSpec.describe API::ResourceAccessTokens do context "when missing the 'scopes' param" do let_it_be(:params) { { name: "test", expires_at: 5.days.from_now } } - it "does not create a project access token without 'scopes'" do + it "does not create a #{source_type} access token without 'scopes'" do create_token expect(response).to have_gitlab_http_status(:bad_request) @@ -292,50 +281,80 @@ RSpec.describe API::ResourceAccessTokens do end end - context "when trying to create a token in a different project" do - let_it_be(:project_id) { other_project.id } + context "when trying to create a token in a different #{source_type}" do + let_it_be(:resource_id) { unknown_resource.id } - it "does not create the token, and returns the project not found error" do + it "does not create the token, and returns the #{source_type} not found error" do create_token expect(response).to have_gitlab_http_status(:not_found) - expect(response.body).to include("Project Not Found") + expect(response.body).to include("#{source_type.capitalize} Not Found") end end end context "when the user does not have valid permissions" do - let_it_be(:project_id) { project.id } + let_it_be(:resource_id) { resource.id } - context "when the user is a developer" do - before do - project.add_developer(user) - end + context "when the user role is too low" do + let_it_be(:user) { user_non_priviledged } it "does not create the token, and returns the permission error" do create_token expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to include("User does not have permission to create project access token") + expect(response.body).to include("User does not have permission to create #{source_type} access token") end end - context "when a project access token tries to create another project access token" do + context "when a #{source_type} access token tries to create another #{source_type} access token" do let_it_be(:project_bot) { create(:user, :project_bot) } let_it_be(:user) { project_bot } before do - project.add_maintainer(user) + if source_type == 'project' + resource.add_maintainer(project_bot) + else + resource.add_owner(project_bot) + end end - it "does not allow a project access token to create another project access token" do + it "does not allow a #{source_type} access token to create another #{source_type} access token" do create_token expect(response).to have_gitlab_http_status(:bad_request) - expect(response.body).to include("User does not have permission to create project access token") + expect(response.body).to include("User does not have permission to create #{source_type} access token") end end end end end + + context 'when the resource is a project' do + let_it_be(:resource) { create(:project) } + let_it_be(:other_resource) { create(:project) } + let_it_be(:unknown_resource) { create(:project) } + + before_all do + resource.add_maintainer(user) + other_resource.add_maintainer(user) + resource.add_developer(user_non_priviledged) + end + + it_behaves_like 'resource access token API', 'project' + end + + context 'when the resource is a group' do + let_it_be(:resource) { create(:group) } + let_it_be(:other_resource) { create(:group) } + let_it_be(:unknown_resource) { create(:project) } + + before_all do + resource.add_owner(user) + other_resource.add_owner(user) + resource.add_maintainer(user_non_priviledged) + end + + it_behaves_like 'resource access token API', 'group' + end end diff --git a/spec/requests/api/rubygem_packages_spec.rb b/spec/requests/api/rubygem_packages_spec.rb index 9b104520b52..0e63a7269e7 100644 --- a/spec/requests/api/rubygem_packages_spec.rb +++ b/spec/requests/api/rubygem_packages_spec.rb @@ -173,6 +173,34 @@ RSpec.describe API::RubygemPackages do it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] end end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, :xml, package: package, file_name: file_name) } + + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + end + + it 'does not return them' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).not_to eq(package_file_pending_destruction.file.file.read) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq(package_file_pending_destruction.file.file.read) + end + end + end end describe 'POST /api/v4/projects/:project_id/packages/rubygems/api/v1/gems/authorize' do diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index b75fe11b06d..24cd95781c3 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -346,6 +346,14 @@ RSpec.describe API::Search do end end end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get api(endpoint, current_user), params: { scope: 'users', search: 'foo@bar.com' } + end + end end describe "GET /groups/:id/search" do @@ -513,6 +521,14 @@ RSpec.describe API::Search do it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics' end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get api(endpoint, current_user), params: { scope: 'users', search: 'foo@bar.com' } + end + end end end @@ -786,6 +802,14 @@ RSpec.describe API::Search do end end end + + it_behaves_like 'rate limited endpoint', rate_limit_key: :user_email_lookup do + let(:current_user) { user } + + def request + get api(endpoint, current_user), params: { scope: 'users', search: 'foo@bar.com' } + end + end end end end diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/packages_spec.rb index b17bc11a451..c0f04ba09be 100644 --- a/spec/requests/api/terraform/modules/v1/packages_spec.rb +++ b/spec/requests/api/terraform/modules/v1/packages_spec.rb @@ -154,6 +154,7 @@ RSpec.describe API::Terraform::Modules::V1::Packages do end describe 'GET /api/v4/packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/:module_version/file' do + let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/#{package.version}/file?token=#{token}") } let(:tokens) do { personal_access_token: ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = personal_access_token.id }.encoded, @@ -202,7 +203,6 @@ RSpec.describe API::Terraform::Modules::V1::Packages do with_them do let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' } - let(:url) { api("/packages/terraform/modules/v1/#{group.path}/#{package.name}/#{package.version}/file?token=#{token}") } let(:snowplow_gitlab_standard_context) { { project: project, user: user, namespace: project.namespace } } before do @@ -212,6 +212,41 @@ RSpec.describe API::Terraform::Modules::V1::Packages do it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] end end + + context 'with package file pending destruction' do + let_it_be(:package) { create(:package, package_type: :terraform_module, project: project, name: "module-555/pending-destruction", version: '1.0.0') } + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, :xml, package: package) } + let_it_be(:package_file) { create(:package_file, :terraform_module, package: package) } + + let(:token) { tokens[:personal_access_token] } + let(:headers) { { 'Authorization' => "Bearer #{token}" } } + + before do + project.add_maintainer(user) + end + + it 'does not return them' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).not_to eq(package_file_pending_destruction.file.file.read) + expect(response.body).to eq(package_file.file.file.read) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq(package_file_pending_destruction.file.file.read) + expect(response.body).not_to eq(package_file.file.file.read) + end + end + end end describe 'PUT /api/v4/projects/:project_id/packages/terraform/modules/:module_name/:module_system/:module_version/file/authorize' do diff --git a/spec/requests/api/usage_data_non_sql_metrics_spec.rb b/spec/requests/api/usage_data_non_sql_metrics_spec.rb index 225af57a267..0b73d0f96a4 100644 --- a/spec/requests/api/usage_data_non_sql_metrics_spec.rb +++ b/spec/requests/api/usage_data_non_sql_metrics_spec.rb @@ -18,6 +18,7 @@ RSpec.describe API::UsageDataNonSqlMetrics do context 'with authentication' do before do stub_feature_flags(usage_data_non_sql_metrics: true) + stub_database_flavor_check end it 'returns non sql metrics if user is admin' do diff --git a/spec/requests/api/usage_data_queries_spec.rb b/spec/requests/api/usage_data_queries_spec.rb index 0ba4a37bc9b..69a8d865a59 100644 --- a/spec/requests/api/usage_data_queries_spec.rb +++ b/spec/requests/api/usage_data_queries_spec.rb @@ -10,6 +10,7 @@ RSpec.describe API::UsageDataQueries do before do stub_usage_data_connections + stub_database_flavor_check end describe 'GET /usage_data/usage_data_queries' do diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index b93df2f3bae..98875d7e8d2 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -498,6 +498,10 @@ RSpec.describe API::Users do describe "GET /users/:id" do let_it_be(:user2, reload: true) { create(:user, username: 'another_user') } + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:users_get_by_id, scope: user).and_return(false) + end + it "returns a user by id" do get api("/users/#{user.id}", user) @@ -593,6 +597,55 @@ RSpec.describe API::Users do expect(json_response).not_to have_key('sign_in_count') end + context 'when the rate limit is not exceeded' do + it 'returns a success status' do + expect(Gitlab::ApplicationRateLimiter) + .to receive(:throttled?).with(:users_get_by_id, scope: user) + .and_return(false) + + get api("/users/#{user.id}", user) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when the rate limit is exceeded' do + context 'when feature flag is enabled' do + it 'returns "too many requests" status' do + expect(Gitlab::ApplicationRateLimiter) + .to receive(:throttled?).with(:users_get_by_id, scope: user) + .and_return(true) + + get api("/users/#{user.id}", user) + + expect(response).to have_gitlab_http_status(:too_many_requests) + end + + it 'still allows admin users' do + expect(Gitlab::ApplicationRateLimiter) + .not_to receive(:throttled?) + + get api("/users/#{user.id}", admin) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(rate_limit_user_by_id_endpoint: false) + end + + it 'does not throttle the request' do + expect(Gitlab::ApplicationRateLimiter).not_to receive(:throttled?) + + get api("/users/#{user.id}", user) + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + context 'when job title is present' do let(:job_title) { 'Fullstack Engineer' } @@ -974,7 +1027,7 @@ RSpec.describe API::Users do post api('/users', admin), params: { email: 'invalid email', - password: 'password', + password: Gitlab::Password.test_default, name: 'test' } expect(response).to have_gitlab_http_status(:bad_request) @@ -1040,7 +1093,7 @@ RSpec.describe API::Users do post api('/users', admin), params: { email: 'test@example.com', - password: 'password', + password: Gitlab::Password.test_default, username: 'test', name: 'foo' } @@ -1052,7 +1105,7 @@ RSpec.describe API::Users do params: { name: 'foo', email: 'test@example.com', - password: 'password', + password: Gitlab::Password.test_default, username: 'foo' } end.to change { User.count }.by(0) @@ -1066,7 +1119,7 @@ RSpec.describe API::Users do params: { name: 'foo', email: 'foo@example.com', - password: 'password', + password: Gitlab::Password.test_default, username: 'test' } end.to change { User.count }.by(0) @@ -1080,7 +1133,7 @@ RSpec.describe API::Users do params: { name: 'foo', email: 'foo@example.com', - password: 'password', + password: Gitlab::Password.test_default, username: 'TEST' } end.to change { User.count }.by(0) @@ -1425,8 +1478,8 @@ RSpec.describe API::Users do context "with existing user" do before do - post api("/users", admin), params: { email: 'test@example.com', password: 'password', username: 'test', name: 'test' } - post api("/users", admin), params: { email: 'foo@bar.com', password: 'password', username: 'john', name: 'john' } + post api("/users", admin), params: { email: 'test@example.com', password: Gitlab::Password.test_default, username: 'test', name: 'test' } + post api("/users", admin), params: { email: 'foo@bar.com', password: Gitlab::Password.test_default, username: 'john', name: 'john' } @user = User.all.last end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index d2528600477..623cf24b9cb 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -319,7 +319,7 @@ RSpec.describe 'Git HTTP requests' do context 'when user is using credentials with special characters' do context 'with password with special characters' do before do - user.update!(password: 'RKszEwéC5kFnû∆f243fycGu§Gh9ftDj!U') + user.update!(password: Gitlab::Password.test_default) end it 'allows clones' do @@ -1670,7 +1670,7 @@ RSpec.describe 'Git HTTP requests' do context 'when user is using credentials with special characters' do context 'with password with special characters' do before do - user.update!(password: 'RKszEwéC5kFnû∆f243fycGu§Gh9ftDj!U') + user.update!(password: Gitlab::Password.test_default) end it 'allows clones' do diff --git a/spec/requests/groups/crm/contacts_controller_spec.rb b/spec/requests/groups/crm/contacts_controller_spec.rb index a4b2a28e77a..5d126c6ead5 100644 --- a/spec/requests/groups/crm/contacts_controller_spec.rb +++ b/spec/requests/groups/crm/contacts_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Groups::Crm::ContactsController do shared_examples 'ok response with index template if authorized' do context 'private group' do - let(:group) { create(:group, :private) } + let(:group) { create(:group, :private, :crm_enabled) } context 'with authorized user' do before do @@ -32,11 +32,17 @@ RSpec.describe Groups::Crm::ContactsController do sign_in(user) end - context 'when feature flag is enabled' do + context 'when crm_enabled is true' do it_behaves_like 'ok response with index template' end - context 'when feature flag is not enabled' do + context 'when crm_enabled is false' do + let(:group) { create(:group, :private) } + + it_behaves_like 'response with 404 status' + end + + context 'when feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -64,10 +70,10 @@ RSpec.describe Groups::Crm::ContactsController do end context 'public group' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } context 'with anonymous user' do - it_behaves_like 'ok response with index template' + it_behaves_like 'response with 404 status' end end end diff --git a/spec/requests/groups/crm/organizations_controller_spec.rb b/spec/requests/groups/crm/organizations_controller_spec.rb index 7595950350d..f38300c3c5b 100644 --- a/spec/requests/groups/crm/organizations_controller_spec.rb +++ b/spec/requests/groups/crm/organizations_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Groups::Crm::OrganizationsController do shared_examples 'ok response with index template if authorized' do context 'private group' do - let(:group) { create(:group, :private) } + let(:group) { create(:group, :private, :crm_enabled) } context 'with authorized user' do before do @@ -32,11 +32,17 @@ RSpec.describe Groups::Crm::OrganizationsController do sign_in(user) end - context 'when feature flag is enabled' do + context 'when crm_enabled is true' do it_behaves_like 'ok response with index template' end - context 'when feature flag is not enabled' do + context 'when crm_enabled is false' do + let(:group) { create(:group, :private) } + + it_behaves_like 'response with 404 status' + end + + context 'when feature flag is disabled' do before do stub_feature_flags(customer_relations: false) end @@ -64,10 +70,10 @@ RSpec.describe Groups::Crm::OrganizationsController do end context 'public group' do - let(:group) { create(:group, :public) } + let(:group) { create(:group, :public, :crm_enabled) } context 'with anonymous user' do - it_behaves_like 'ok response with index template' + it_behaves_like 'response with 404 status' end end end diff --git a/spec/requests/groups/settings/access_tokens_controller_spec.rb b/spec/requests/groups/settings/access_tokens_controller_spec.rb new file mode 100644 index 00000000000..eabdef3c41e --- /dev/null +++ b/spec/requests/groups/settings/access_tokens_controller_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::Settings::AccessTokensController do + let_it_be(:user) { create(:user) } + let_it_be(:resource) { create(:group) } + let_it_be(:bot_user) { create(:user, :project_bot) } + + before_all do + resource.add_owner(user) + resource.add_maintainer(bot_user) + end + + before do + sign_in(user) + end + + shared_examples 'feature unavailable' do + context 'user is not a owner' do + before do + resource.add_maintainer(user) + end + + it { expect(subject).to have_gitlab_http_status(:not_found) } + end + end + + describe 'GET /:namespace/-/settings/access_tokens' do + subject do + get group_settings_access_tokens_path(resource) + response + end + + it_behaves_like 'feature unavailable' + it_behaves_like 'GET resource access tokens available' + end + + describe 'POST /:namespace/-/settings/access_tokens' do + let(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: Date.today + 1.month } } + + subject do + post group_settings_access_tokens_path(resource), params: { resource_access_token: access_token_params } + response + end + + it_behaves_like 'feature unavailable' + it_behaves_like 'POST resource access tokens available' + + context 'when group access token creation is disabled' do + before do + resource.namespace_settings.update_column(:resource_access_token_creation_allowed, false) + end + + it { expect(subject).to have_gitlab_http_status(:not_found) } + + it 'does not create the token' do + expect { subject }.not_to change { PersonalAccessToken.count } + end + + it 'does not add the project bot as a member' do + expect { subject }.not_to change { Member.count } + end + + it 'does not create the project bot user' do + expect { subject }.not_to change { User.count } + end + end + + context 'with custom access level' do + let(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: Date.today + 1.month, access_level: 20 } } + + subject { post group_settings_access_tokens_path(resource), params: { resource_access_token: access_token_params } } + + it_behaves_like 'POST resource access tokens available' + end + end + + describe 'PUT /:namespace/-/settings/access_tokens/:id', :sidekiq_inline do + let(:resource_access_token) { create(:personal_access_token, user: bot_user) } + + subject do + put revoke_group_settings_access_token_path(resource, resource_access_token) + response + end + + it_behaves_like 'feature unavailable' + it_behaves_like 'PUT resource access tokens available' + end +end diff --git a/spec/requests/projects/google_cloud/deployments_controller_spec.rb b/spec/requests/projects/google_cloud/deployments_controller_spec.rb new file mode 100644 index 00000000000..a5eccc43147 --- /dev/null +++ b/spec/requests/projects/google_cloud/deployments_controller_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::GoogleCloud::DeploymentsController do + let_it_be(:project) { create(:project, :public) } + + let_it_be(:user_guest) { create(:user) } + let_it_be(:user_developer) { create(:user) } + let_it_be(:user_maintainer) { create(:user) } + let_it_be(:user_creator) { project.creator } + + let_it_be(:unauthorized_members) { [user_guest, user_developer] } + let_it_be(:authorized_members) { [user_maintainer, user_creator] } + + let_it_be(:urls_list) { %W[#{project_google_cloud_deployments_cloud_run_path(project)} #{project_google_cloud_deployments_cloud_storage_path(project)}] } + + before do + project.add_guest(user_guest) + project.add_developer(user_developer) + project.add_maintainer(user_maintainer) + end + + describe "Routes must be restricted behind Google OAuth2" do + context 'when a public request is made' do + it 'returns not found on GET request' do + urls_list.each do |url| + get url + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when unauthorized members make requests' do + it 'returns not found on GET request' do + urls_list.each do |url| + unauthorized_members.each do |unauthorized_member| + sign_in(unauthorized_member) + + get url + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end + + context 'when authorized members make requests' do + it 'redirects on GET request' do + urls_list.each do |url| + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to redirect_to(assigns(:authorize_url)) + end + end + end + end + end + + describe 'Authorized GET project/-/google_cloud/deployments/cloud_run' do + let_it_be(:url) { "#{project_google_cloud_deployments_cloud_run_path(project)}" } + + before do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + allow(client).to receive(:validate_token).and_return(true) + end + end + + it 'renders placeholder' do + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + describe 'Authorized GET project/-/google_cloud/deployments/cloud_storage' do + let_it_be(:url) { "#{project_google_cloud_deployments_cloud_storage_path(project)}" } + + before do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + allow(client).to receive(:validate_token).and_return(true) + end + end + + it 'renders placeholder' do + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to have_gitlab_http_status(:ok) + end + end + end +end diff --git a/spec/requests/projects/merge_requests/context_commit_diffs_spec.rb b/spec/requests/projects/merge_requests/context_commit_diffs_spec.rb index 434e6f19ff5..7be863aae75 100644 --- a/spec/requests/projects/merge_requests/context_commit_diffs_spec.rb +++ b/spec/requests/projects/merge_requests/context_commit_diffs_spec.rb @@ -31,7 +31,6 @@ RSpec.describe 'Merge Requests Context Commit Diffs' do def collection_arguments(pagination_data = {}) { - environment: nil, merge_request: merge_request, commit: nil, diff_view: :inline, diff --git a/spec/requests/projects/merge_requests/diffs_spec.rb b/spec/requests/projects/merge_requests/diffs_spec.rb index ad50c39c65d..e17be1ff984 100644 --- a/spec/requests/projects/merge_requests/diffs_spec.rb +++ b/spec/requests/projects/merge_requests/diffs_spec.rb @@ -29,7 +29,6 @@ RSpec.describe 'Merge Requests Diffs' do def collection_arguments(pagination_data = {}) { - environment: nil, merge_request: merge_request, commit: nil, diff_view: :inline, @@ -110,21 +109,6 @@ RSpec.describe 'Merge Requests Diffs' do end end - context 'with a new environment' do - let(:environment) do - create(:environment, :available, project: project) - end - - let!(:deployment) do - create(:deployment, :success, environment: environment, ref: merge_request.source_branch) - end - - it_behaves_like 'serializes diffs with expected arguments' do - let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch } - let(:expected_options) { collection_arguments(total_pages: 20).merge(environment: environment) } - end - end - context 'with disabled display_merge_conflicts_in_diff feature' do before do stub_feature_flags(display_merge_conflicts_in_diff: false) diff --git a/spec/requests/projects/merge_requests_discussions_spec.rb b/spec/requests/projects/merge_requests_discussions_spec.rb index 4921a43ab8b..6cf7bfb1795 100644 --- a/spec/requests/projects/merge_requests_discussions_spec.rb +++ b/spec/requests/projects/merge_requests_discussions_spec.rb @@ -244,7 +244,7 @@ RSpec.describe 'merge requests discussions' do context 'when current_user role changes' do before do - Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(project.project_member(user)) + Members::UpdateService.new(owner, access_level: Gitlab::Access::GUEST).execute(project.member(user)) end it_behaves_like 'cache miss' do diff --git a/spec/controllers/projects/settings/access_tokens_controller_spec.rb b/spec/requests/projects/settings/access_tokens_controller_spec.rb index 834a9e276f9..780d1b8caef 100644 --- a/spec/controllers/projects/settings/access_tokens_controller_spec.rb +++ b/spec/requests/projects/settings/access_tokens_controller_spec.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true -require('spec_helper') +require 'spec_helper' RSpec.describe Projects::Settings::AccessTokensController do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, group: group) } + let_it_be(:resource) { create(:project, group: group) } let_it_be(:bot_user) { create(:user, :project_bot) } before_all do - project.add_maintainer(user) - project.add_maintainer(bot_user) + resource.add_maintainer(user) + resource.add_maintainer(bot_user) end before do @@ -20,34 +20,40 @@ RSpec.describe Projects::Settings::AccessTokensController do shared_examples 'feature unavailable' do context 'user is not a maintainer' do before do - project.add_developer(user) + resource.add_developer(user) end - it { is_expected.to have_gitlab_http_status(:not_found) } + it { expect(subject).to have_gitlab_http_status(:not_found) } end end - describe '#index' do - subject { get :index, params: { namespace_id: project.namespace, project_id: project } } + describe 'GET /:namespace/:project/-/settings/access_tokens' do + subject do + get project_settings_access_tokens_path(resource) + response + end it_behaves_like 'feature unavailable' - it_behaves_like 'project access tokens available #index' + it_behaves_like 'GET resource access tokens available' end - describe '#create' do + describe 'POST /:namespace/:project/-/settings/access_tokens' do let(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: Date.today + 1.month } } - subject { post :create, params: { namespace_id: project.namespace, project_id: project }.merge(project_access_token: access_token_params) } + subject do + post project_settings_access_tokens_path(resource), params: { resource_access_token: access_token_params } + response + end it_behaves_like 'feature unavailable' - it_behaves_like 'project access tokens available #create' + it_behaves_like 'POST resource access tokens available' context 'when project access token creation is disabled' do before do group.namespace_settings.update_column(:resource_access_token_creation_allowed, false) end - it { is_expected.to have_gitlab_http_status(:not_found) } + it { expect(subject).to have_gitlab_http_status(:not_found) } it 'does not create the token' do expect { subject }.not_to change { PersonalAccessToken.count } @@ -65,18 +71,21 @@ RSpec.describe Projects::Settings::AccessTokensController do context 'with custom access level' do let(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: Date.today + 1.month, access_level: 20 } } - subject { post :create, params: { namespace_id: project.namespace, project_id: project }.merge(project_access_token: access_token_params) } + subject { post project_settings_access_tokens_path(resource), params: { resource_access_token: access_token_params } } - it_behaves_like 'project access tokens available #create' + it_behaves_like 'POST resource access tokens available' end end - describe '#revoke', :sidekiq_inline do - let(:project_access_token) { create(:personal_access_token, user: bot_user) } + describe 'PUT /:namespace/:project/-/settings/access_tokens/:id', :sidekiq_inline do + let(:resource_access_token) { create(:personal_access_token, user: bot_user) } - subject { put :revoke, params: { namespace_id: project.namespace, project_id: project, id: project_access_token } } + subject do + put revoke_project_settings_access_token_path(resource, resource_access_token) + response + end it_behaves_like 'feature unavailable' - it_behaves_like 'project access tokens available #revoke' + it_behaves_like 'PUT resource access tokens available' end end diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb index 244ec111a0c..793438808a5 100644 --- a/spec/requests/rack_attack_global_spec.rb +++ b/spec/requests/rack_attack_global_spec.rb @@ -499,9 +499,7 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac before do group.add_owner(user) - group.create_dependency_proxy_setting!(enabled: true) other_group.add_owner(other_user) - other_group.create_dependency_proxy_setting!(enabled: true) allow(Gitlab.config.dependency_proxy) .to receive(:enabled).and_return(true) @@ -533,16 +531,10 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac context 'getting a blob' do let_it_be(:blob) { create(:dependency_proxy_blob) } + let_it_be(:other_blob) { create(:dependency_proxy_blob) } - let(:path) { "/v2/#{group.path}/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e" } - let(:other_path) { "/v2/#{other_group.path}/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e" } - let(:blob_response) { { status: :success, blob: blob, from_cache: false } } - - before do - allow_next_instance_of(DependencyProxy::FindOrCreateBlobService) do |instance| - allow(instance).to receive(:execute).and_return(blob_response) - end - end + let(:path) { "/v2/#{blob.group.path}/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e" } + let(:other_path) { "/v2/#{other_blob.group.path}/dependency_proxy/containers/alpine/blobs/sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e" } it_behaves_like 'rate-limited token-authenticated requests' end diff --git a/spec/requests/recursive_webhook_detection_spec.rb b/spec/requests/recursive_webhook_detection_spec.rb new file mode 100644 index 00000000000..a3014bf1d73 --- /dev/null +++ b/spec/requests/recursive_webhook_detection_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Recursive webhook detection', :sidekiq_inline, :clean_gitlab_redis_shared_state, :request_store do + include StubRequests + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace, creator: user) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:project_hook) { create(:project_hook, project: project, merge_requests_events: true) } + let_it_be(:system_hook) { create(:system_hook, merge_requests_events: true) } + + # Trigger a change to the merge request to fire the webhooks. + def trigger_web_hooks + params = { merge_request: { description: FFaker::Lorem.sentence } } + put project_merge_request_path(project, merge_request), params: params, headers: headers + end + + def stub_requests + stub_full_request(project_hook.url, method: :post, ip_address: '8.8.8.8') + stub_full_request(system_hook.url, method: :post, ip_address: '8.8.8.9') + end + + before do + login_as(user) + end + + context 'when the request headers include the recursive webhook detection header' do + let(:uuid) { SecureRandom.uuid } + let(:headers) { { Gitlab::WebHooks::RecursionDetection::UUID::HEADER => uuid } } + + it 'executes all webhooks, logs no errors, and the webhook requests contain the same UUID header', :aggregate_failures do + stub_requests + + expect(Gitlab::AuthLogger).not_to receive(:error) + + trigger_web_hooks + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with { |req| req.headers['X-Gitlab-Event-Uuid'] == uuid } + .once + expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)) + .with { |req| req.headers['X-Gitlab-Event-Uuid'] == uuid } + .once + end + + context 'when one of the webhooks is recursive' do + before do + # Recreate the necessary state for the previous request to be + # considered made from the webhook. + Gitlab::WebHooks::RecursionDetection.set_request_uuid(uuid) + Gitlab::WebHooks::RecursionDetection.register!(project_hook) + Gitlab::WebHooks::RecursionDetection.set_request_uuid(nil) + end + + it 'executes all webhooks and logs an error for the recursive hook', :aggregate_failures do + stub_requests + + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: project_hook.id, + recursion_detection: { + uuid: uuid, + ids: [project_hook.id] + } + ) + ).twice # Twice: once in `#async_execute`, and again in `#execute`. + + trigger_web_hooks + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).once + expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).once + end + end + + context 'when the count limit has been reached' do + let_it_be(:previous_hooks) { create_list(:project_hook, 3) } + + before do + stub_const('Gitlab::WebHooks::RecursionDetection::COUNT_LIMIT', 2) + # Recreate the necessary state for a number of previous webhooks to + # have been triggered previously. + Gitlab::WebHooks::RecursionDetection.set_request_uuid(uuid) + previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) } + Gitlab::WebHooks::RecursionDetection.set_request_uuid(nil) + end + + it 'executes and logs errors for all hooks', :aggregate_failures do + stub_requests + previous_hook_ids = previous_hooks.map(&:id) + + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: project_hook.id, + recursion_detection: { + uuid: uuid, + ids: include(*previous_hook_ids) + } + ) + ).twice + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: system_hook.id, + recursion_detection: { + uuid: uuid, + ids: include(*previous_hook_ids) + } + ) + ).twice + + trigger_web_hooks + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).once + expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).once + end + end + end + + context 'when the recursive webhook detection header is absent' do + let(:headers) { {} } + + let(:uuid_header_spy) do + Class.new do + attr_reader :values + + def initialize + @values = [] + end + + def to_proc + proc do |method, *args| + method.call(*args).tap do |headers| + @values << headers[Gitlab::WebHooks::RecursionDetection::UUID::HEADER] + end + end + end + end.new + end + + before do + allow(Gitlab::WebHooks::RecursionDetection).to receive(:header).at_least(:once).and_wrap_original(&uuid_header_spy) + end + + it 'executes all webhooks, logs no errors, and the webhook requests contain different UUID headers', :aggregate_failures do + stub_requests + + expect(Gitlab::AuthLogger).not_to receive(:error) + + trigger_web_hooks + + uuid_headers = uuid_header_spy.values + + expect(uuid_headers).to all(be_present) + expect(uuid_headers.uniq.length).to eq(2) + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with { |req| uuid_headers.include?(req.headers['X-Gitlab-Event-Uuid']) } + .once + expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)) + .with { |req| uuid_headers.include?(req.headers['X-Gitlab-Event-Uuid']) } + .once + end + + it 'uses new UUID values between requests' do + stub_requests + + trigger_web_hooks + trigger_web_hooks + + uuid_headers = uuid_header_spy.values + + expect(uuid_headers).to all(be_present) + expect(uuid_headers.length).to eq(4) + expect(uuid_headers.uniq.length).to eq(4) + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).twice + expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).twice + end + end +end diff --git a/spec/requests/sandbox_controller_spec.rb b/spec/requests/sandbox_controller_spec.rb new file mode 100644 index 00000000000..4fc26580123 --- /dev/null +++ b/spec/requests/sandbox_controller_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SandboxController do + describe 'GET #mermaid' do + it 'renders page without template' do + get sandbox_mermaid_path + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(layout: nil) + end + end +end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index eefc24f7824..dacc11eece7 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -636,6 +636,8 @@ RSpec.describe UsersController do describe 'GET #exists' do before do sign_in(user) + + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(false) end context 'when user exists' do @@ -677,6 +679,17 @@ RSpec.describe UsersController do end end end + + context 'when the rate limit has been reached' do + it 'returns status 429 Too Many Requests', :aggregate_failures do + ip = '1.2.3.4' + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:username_exists, scope: ip).and_return(true) + + get user_exists_url(user.username), env: { 'REMOTE_ADDR': ip } + + expect(response).to have_gitlab_http_status(:too_many_requests) + end + end end describe '#ensure_canonical_path' do diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index e7ea5b79897..79edfdd2b3f 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -364,6 +364,12 @@ RSpec.describe AutocompleteController, 'routing' do end end +RSpec.describe SandboxController, 'routing' do + it 'to #mermaid' do + expect(get("/-/sandbox/mermaid")).to route_to('sandbox#mermaid') + end +end + RSpec.describe Snippets::BlobsController, "routing" do it "to #raw" do expect(get('/-/snippets/1/raw/master/lib/version.rb')) diff --git a/spec/rubocop/code_reuse_helpers_spec.rb b/spec/rubocop/code_reuse_helpers_spec.rb index 3220cff1681..d437ada85ee 100644 --- a/spec/rubocop/code_reuse_helpers_spec.rb +++ b/spec/rubocop/code_reuse_helpers_spec.rb @@ -315,76 +315,11 @@ RSpec.describe RuboCop::CodeReuseHelpers do end end - describe '#ee?' do - before do - stub_env('FOSS_ONLY', nil) - allow(File).to receive(:exist?).with(ee_file_path) { true } - end - - it 'returns true when ee/app/models/license.rb exists' do - expect(cop.ee?).to eq(true) - end - end - - describe '#jh?' do - context 'when jh directory exists and EE_ONLY is not set' do - before do - stub_env('EE_ONLY', nil) - - allow(Dir).to receive(:exist?).with(File.expand_path('../../jh', __dir__)) { true } - end - - context 'when ee/app/models/license.rb exists' do - before do - allow(File).to receive(:exist?).with(ee_file_path) { true } - end - - context 'when FOSS_ONLY is not set' do - before do - stub_env('FOSS_ONLY', nil) - end - - it 'returns true' do - expect(cop.jh?).to eq(true) - end - end - - context 'when FOSS_ONLY is set to 1' do - before do - stub_env('FOSS_ONLY', '1') - end + %w[ee? jh?].each do |method_name| + it "delegates #{method_name} to GitlabEdition" do + expect(GitlabEdition).to receive(method_name) - it 'returns false' do - expect(cop.jh?).to eq(false) - end - end - end - - context 'when ee/app/models/license.rb not exist' do - before do - allow(File).to receive(:exist?).with(ee_file_path) { false } - end - - context 'when FOSS_ONLY is not set' do - before do - stub_env('FOSS_ONLY', nil) - end - - it 'returns true' do - expect(cop.jh?).to eq(false) - end - end - - context 'when FOSS_ONLY is set to 1' do - before do - stub_env('FOSS_ONLY', '1') - end - - it 'returns false' do - expect(cop.jh?).to eq(false) - end - end - end + cop.public_send(method_name) end end end diff --git a/spec/rubocop/cop/database/establish_connection_spec.rb b/spec/rubocop/cop/database/establish_connection_spec.rb new file mode 100644 index 00000000000..a3c27d33cb0 --- /dev/null +++ b/spec/rubocop/cop/database/establish_connection_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../rubocop/cop/database/establish_connection' + +RSpec.describe RuboCop::Cop::Database::EstablishConnection do + subject(:cop) { described_class.new } + + it 'flags the use of ActiveRecord::Base.establish_connection' do + expect_offense(<<~CODE) + ActiveRecord::Base.establish_connection + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't establish new database [...] + CODE + end + + it 'flags the use of ActiveRecord::Base.establish_connection with arguments' do + expect_offense(<<~CODE) + ActiveRecord::Base.establish_connection(:foo) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't establish new database [...] + CODE + end + + it 'flags the use of SomeModel.establish_connection' do + expect_offense(<<~CODE) + SomeModel.establish_connection + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't establish new database [...] + CODE + end +end diff --git a/spec/rubocop/cop/migration/prevent_global_enable_lock_retries_with_disable_ddl_transaction_spec.rb b/spec/rubocop/cop/migration/prevent_global_enable_lock_retries_with_disable_ddl_transaction_spec.rb new file mode 100644 index 00000000000..aa63259288d --- /dev/null +++ b/spec/rubocop/cop/migration/prevent_global_enable_lock_retries_with_disable_ddl_transaction_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../../rubocop/cop/migration/prevent_global_enable_lock_retries_with_disable_ddl_transaction' + +RSpec.describe RuboCop::Cop::Migration::PreventGlobalEnableLockRetriesWithDisableDdlTransaction do + subject(:cop) { described_class.new } + + context 'when in migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + it 'registers an offense when `enable_lock_retries` and `disable_ddl_transaction` is used together' do + code = <<~RUBY + class SomeMigration < ActiveRecord::Migration[6.0] + enable_lock_retries! + disable_ddl_transaction! + end + RUBY + + expect_offense(<<~RUBY, node: code, msg: described_class::MSG) + class SomeMigration < ActiveRecord::Migration[6.0] + enable_lock_retries! + disable_ddl_transaction! + ^^^^^^^^^^^^^^^^^^^^^^^^ %{msg} + end + RUBY + end + + it 'registers no offense when `enable_lock_retries!` is used' do + expect_no_offenses(<<~RUBY) + class SomeMigration < ActiveRecord::Migration[6.0] + enable_lock_retries! + end + RUBY + end + + it 'registers no offense when `disable_ddl_transaction!` is used' do + expect_no_offenses(<<~RUBY) + class SomeMigration < ActiveRecord::Migration[6.0] + disable_ddl_transaction! + end + RUBY + end + end + + context 'when outside of migration' do + it 'registers no offense' do + expect_no_offenses(<<~RUBY) + class SomeMigration + enable_lock_retries! + disable_ddl_transaction! + end + RUBY + end + end +end diff --git a/spec/rubocop/cop/migration/schedule_async_spec.rb b/spec/rubocop/cop/migration/schedule_async_spec.rb index b89acb6db41..5f848dd9b66 100644 --- a/spec/rubocop/cop/migration/schedule_async_spec.rb +++ b/spec/rubocop/cop/migration/schedule_async_spec.rb @@ -43,24 +43,18 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do end context 'BackgroundMigrationWorker.perform_async' do - it 'adds an offense when calling `BackgroundMigrationWorker.peform_async` and corrects', :aggregate_failures do + it 'adds an offense when calling `BackgroundMigrationWorker.peform_async`' do expect_offense(<<~RUBY) def up BackgroundMigrationWorker.perform_async(ClazzName, "Bar", "Baz") ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't call [...] end RUBY - - expect_correction(<<~RUBY) - def up - migrate_async(ClazzName, "Bar", "Baz") - end - RUBY end end context 'BackgroundMigrationWorker.perform_in' do - it 'adds an offense and corrects', :aggregate_failures do + it 'adds an offense' do expect_offense(<<~RUBY) def up BackgroundMigrationWorker @@ -68,17 +62,11 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do .perform_in(delay, ClazzName, "Bar", "Baz") end RUBY - - expect_correction(<<~RUBY) - def up - migrate_in(delay, ClazzName, "Bar", "Baz") - end - RUBY end end context 'BackgroundMigrationWorker.bulk_perform_async' do - it 'adds an offense and corrects', :aggregate_failures do + it 'adds an offense' do expect_offense(<<~RUBY) def up BackgroundMigrationWorker @@ -86,17 +74,11 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do .bulk_perform_async(jobs) end RUBY - - expect_correction(<<~RUBY) - def up - bulk_migrate_async(jobs) - end - RUBY end end context 'BackgroundMigrationWorker.bulk_perform_in' do - it 'adds an offense and corrects', :aggregate_failures do + it 'adds an offense' do expect_offense(<<~RUBY) def up BackgroundMigrationWorker @@ -104,12 +86,6 @@ RSpec.describe RuboCop::Cop::Migration::ScheduleAsync do .bulk_perform_in(5.minutes, jobs) end RUBY - - expect_correction(<<~RUBY) - def up - bulk_migrate_in(5.minutes, jobs) - end - RUBY end end end diff --git a/spec/scripts/setup/find_jh_branch_spec.rb b/spec/scripts/setup/find_jh_branch_spec.rb new file mode 100644 index 00000000000..dfc3601ffa9 --- /dev/null +++ b/spec/scripts/setup/find_jh_branch_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +# NOTE: Under the context of fast_spec_helper, when we `require 'gitlab'` +# we do not load the Gitlab client, but our own Gitlab module. +# Keep this in mind and just stub anything which might touch it! +require_relative '../../../scripts/setup/find-jh-branch' + +RSpec.describe FindJhBranch do + subject { described_class.new } + + describe '#run' do + context 'when it is not a merge request' do + before do + expect(subject).to receive(:merge_request?).and_return(false) + end + + it 'returns JH_DEFAULT_BRANCH' do + expect(subject.run).to eq(described_class::JH_DEFAULT_BRANCH) + end + end + + context 'when it is a merge request' do + let(:branch_name) { 'branch-name' } + let(:jh_branch_name) { 'branch-name-jh' } + let(:default_branch) { 'main' } + let(:merge_request) { double(target_branch: target_branch) } + let(:target_branch) { default_branch } + + before do + expect(subject).to receive(:merge_request?).and_return(true) + + expect(subject) + .to receive(:branch_exist?) + .with(described_class::JH_PROJECT_PATH, jh_branch_name) + .and_return(jh_branch_exist) + + allow(subject).to receive(:ref_name).and_return(branch_name) + allow(subject).to receive(:default_branch).and_return(default_branch) + allow(subject).to receive(:merge_request).and_return(merge_request) + end + + context 'when there is a corresponding JH branch' do + let(:jh_branch_exist) { true } + + it 'returns the corresponding JH branch name' do + expect(subject.run).to eq(jh_branch_name) + end + end + + context 'when there is no corresponding JH branch' do + let(:jh_branch_exist) { false } + + it 'returns the default JH branch' do + expect(subject.run).to eq(described_class::JH_DEFAULT_BRANCH) + end + + context 'when it is targeting a default branch' do + let(:target_branch) { '14-6-stable-ee' } + let(:jh_stable_branch_name) { '14-6-stable-jh' } + + before do + expect(subject) + .to receive(:branch_exist?) + .with(described_class::JH_PROJECT_PATH, jh_stable_branch_name) + .and_return(jh_stable_branch_exist) + end + + context 'when there is a corresponding JH stable branch' do + let(:jh_stable_branch_exist) { true } + + it 'returns the corresponding JH stable branch' do + expect(subject.run).to eq(jh_stable_branch_name) + end + end + + context 'when there is no corresponding JH stable branch' do + let(:jh_stable_branch_exist) { false } + + it "raises #{described_class::BranchNotFound}" do + expect { subject.run }.to raise_error(described_class::BranchNotFound) + end + end + end + + context 'when it is not targeting the default branch' do + let(:target_branch) { default_branch.swapcase } + + it 'returns the default JH branch' do + expect(subject.run).to eq(described_class::JH_DEFAULT_BRANCH) + end + end + end + end + end +end diff --git a/spec/serializers/analytics_build_entity_spec.rb b/spec/serializers/analytics_build_entity_spec.rb index 09804681f5d..b5678d91248 100644 --- a/spec/serializers/analytics_build_entity_spec.rb +++ b/spec/serializers/analytics_build_entity_spec.rb @@ -27,6 +27,14 @@ RSpec.describe AnalyticsBuildEntity do expect(subject).to include(:author) end + it 'contains the project path' do + expect(subject).to include(:project_path) + end + + it 'contains the namespace full path' do + expect(subject).to include(:namespace_full_path) + end + it 'does not contain sensitive information' do expect(subject).not_to include(/token/) expect(subject).not_to include(/variables/) diff --git a/spec/serializers/analytics_issue_entity_spec.rb b/spec/serializers/analytics_issue_entity_spec.rb index 447c5e7d02a..bc5cab638cd 100644 --- a/spec/serializers/analytics_issue_entity_spec.rb +++ b/spec/serializers/analytics_issue_entity_spec.rb @@ -32,6 +32,14 @@ RSpec.describe AnalyticsIssueEntity do expect(subject).to include(:author) end + it 'contains the project path' do + expect(subject).to include(:project_path) + end + + it 'contains the namespace full path' do + expect(subject).to include(:namespace_full_path) + end + it 'does not contain sensitive information' do expect(subject).not_to include(/token/) expect(subject).not_to include(/variables/) diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb index 985e18f27a0..80b6f00d8c9 100644 --- a/spec/serializers/environment_serializer_spec.rb +++ b/spec/serializers/environment_serializer_spec.rb @@ -185,6 +185,42 @@ RSpec.describe EnvironmentSerializer do end end + context 'batching loading' do + let(:resource) { Environment.all } + + before do + create(:environment, name: 'staging/review-1') + create_environment_with_associations(project) + end + + it 'uses the custom preloader service' do + expect_next_instance_of(Preloaders::Environments::DeploymentPreloader) do |preloader| + expect(preloader).to receive(:execute_with_union).with(:last_deployment, hash_including(:deployable)).and_call_original + end + + expect_next_instance_of(Preloaders::Environments::DeploymentPreloader) do |preloader| + expect(preloader).to receive(:execute_with_union).with(:upcoming_deployment, hash_including(:deployable)).and_call_original + end + + json + end + + # Including for test coverage pipeline failure, remove along with feature flag. + context 'when custom preload feature is disabled' do + before do + Feature.disable(:custom_preloader_for_deployments) + end + + it 'avoids N+1 database queries' do + control_count = ActiveRecord::QueryRecorder.new { json }.count + + create_environment_with_associations(project) + + expect { json }.not_to exceed_query_limit(control_count) + end + end + end + def create_environment_with_associations(project) create(:environment, project: project).tap do |environment| create(:deployment, :success, environment: environment, project: project) diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb index e4844c25067..59340181075 100644 --- a/spec/serializers/group_child_entity_spec.rb +++ b/spec/serializers/group_child_entity_spec.rb @@ -62,6 +62,10 @@ RSpec.describe GroupChildEntity do expect(json[:edit_path]).to eq(edit_project_path(object)) end + it 'includes the last activity at' do + expect(json[:last_activity_at]).to be_present + end + it_behaves_like 'group child json' end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index 587d167520f..f5398013a70 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -202,7 +202,7 @@ RSpec.describe PipelineSerializer do # Existing numbers are high and require performance optimization # Ongoing issue: # https://gitlab.com/gitlab-org/gitlab/-/issues/225156 - expected_queries = Gitlab.ee? ? 74 : 70 + expected_queries = Gitlab.ee? ? 78 : 74 expect(recorded.count).to be_within(2).of(expected_queries) expect(recorded.cached_count).to eq(0) diff --git a/spec/services/alert_management/alerts/update_service_spec.rb b/spec/services/alert_management/alerts/update_service_spec.rb index 4b47efca9ed..35697ac79a0 100644 --- a/spec/services/alert_management/alerts/update_service_spec.rb +++ b/spec/services/alert_management/alerts/update_service_spec.rb @@ -235,6 +235,59 @@ RSpec.describe AlertManagement::Alerts::UpdateService do it_behaves_like 'adds a system note' end + + context 'with an associated issue' do + let_it_be(:issue, reload: true) { create(:issue, project: project) } + + before do + alert.update!(issue: issue) + end + + shared_examples 'does not sync with the incident status' do + specify do + expect(::Issues::UpdateService).not_to receive(:new) + expect { response }.to change { alert.acknowledged? }.to(true) + end + end + + it_behaves_like 'does not sync with the incident status' + + context 'when the issue is an incident' do + before do + issue.update!(issue_type: Issue.issue_types[:incident]) + end + + it_behaves_like 'does not sync with the incident status' + + context 'when the incident has an escalation status' do + let_it_be(:escalation_status, reload: true) { create(:incident_management_issuable_escalation_status, issue: issue) } + + it 'updates the incident escalation status with the new alert status' do + expect(::Issues::UpdateService).to receive(:new).once.and_call_original + expect(described_class).to receive(:new).once.and_call_original + + expect { response }.to change { escalation_status.reload.acknowledged? }.to(true) + .and change { alert.reload.acknowledged? }.to(true) + end + + context 'when the statuses match' do + before do + escalation_status.update!(status_event: :acknowledge) + end + + it_behaves_like 'does not sync with the incident status' + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it_behaves_like 'does not sync with the incident status' + end + end + end + end end end end diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb index ce7b43972da..0379fd3f05c 100644 --- a/spec/services/audit_event_service_spec.rb +++ b/spec/services/audit_event_service_spec.rb @@ -60,17 +60,18 @@ RSpec.describe AuditEventService do ip_address: user.current_sign_in_ip, result: AuthenticationEvent.results[:success], provider: 'standard' - ) + ).and_call_original audit_service.for_authentication.security_event end it 'tracks exceptions when the event cannot be created' do - allow(user).to receive_messages(current_sign_in_ip: 'invalid IP') + allow_next_instance_of(AuditEvent) do |event| + allow(event).to receive(:valid?).and_return(false) + end expect(Gitlab::ErrorTracking).to( - receive(:track_exception) - .with(ActiveRecord::RecordInvalid, audit_event_type: 'AuthenticationEvent').and_call_original + receive(:track_and_raise_for_dev_exception) ) audit_service.for_authentication.security_event @@ -93,7 +94,7 @@ RSpec.describe AuditEventService do end specify do - expect(AuthenticationEvent).to receive(:new).with(hash_including(ip_address: output)) + expect(AuthenticationEvent).to receive(:new).with(hash_including(ip_address: output)).and_call_original audit_service.for_authentication.security_event end diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb index 46cc027fcb3..83f77780b80 100644 --- a/spec/services/auth/container_registry_authentication_service_spec.rb +++ b/spec/services/auth/container_registry_authentication_service_spec.rb @@ -92,6 +92,35 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do it_behaves_like 'a modified token' end + + context 'with a project with a path with trailing underscore' do + let(:bad_project) { create(:project) } + + before do + bad_project.update!(path: bad_project.path + '_') + bad_project.add_developer(current_user) + end + + describe '#full_access_token' do + let(:token) { described_class.full_access_token(bad_project.full_path) } + let(:access) do + [{ 'type' => 'repository', + 'name' => bad_project.full_path, + 'actions' => ['*'], + 'migration_eligible' => false }] + end + + subject { { token: token } } + + it 'logs an exception and returns a valid access token' do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + + expect(token).to be_present + expect(payload).to be_a(Hash) + expect(payload).to include('access' => access) + end + end + end end context 'when not in migration mode' do @@ -116,4 +145,28 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do it_behaves_like 'an unmodified token' end end + + context 'CDN redirection' do + include_context 'container registry auth service context' + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:current_params) { { scopes: ["repository:#{project.full_path}:pull"] } } + + before do + project.add_developer(current_user) + end + + it_behaves_like 'a valid token' + it { expect(payload['access']).to include(include('cdn_redirect' => true)) } + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(container_registry_cdn_redirect: false) + end + + it_behaves_like 'a valid token' + it { expect(payload['access']).not_to include(have_key('cdn_redirect')) } + end + end end diff --git a/spec/services/branches/delete_merged_service_spec.rb b/spec/services/branches/delete_merged_service_spec.rb index 2cf0f53c8c3..46611670fe1 100644 --- a/spec/services/branches/delete_merged_service_spec.rb +++ b/spec/services/branches/delete_merged_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Branches::DeleteMergedService do include ProjectForksHelper - subject(:service) { described_class.new(project, project.owner) } + subject(:service) { described_class.new(project, project.first_owner) } let(:project) { create(:project, :repository) } diff --git a/spec/services/bulk_imports/archive_extraction_service_spec.rb b/spec/services/bulk_imports/archive_extraction_service_spec.rb index aa823d88010..da9df31cde9 100644 --- a/spec/services/bulk_imports/archive_extraction_service_spec.rb +++ b/spec/services/bulk_imports/archive_extraction_service_spec.rb @@ -34,9 +34,9 @@ RSpec.describe BulkImports::ArchiveExtractionService do context 'when dir is not in tmpdir' do it 'raises an error' do - ['/etc', '/usr', '/', '/home', '', '/some/other/path', Rails.root].each do |path| + ['/etc', '/usr', '/', '/home', '/some/other/path', Rails.root.to_s].each do |path| expect { described_class.new(tmpdir: path, filename: 'filename').execute } - .to raise_error(BulkImports::Error, 'Invalid target directory') + .to raise_error(StandardError, "path #{path} is not allowed") end end end @@ -52,7 +52,7 @@ RSpec.describe BulkImports::ArchiveExtractionService do context 'when filepath is being traversed' do it 'raises an error' do - expect { described_class.new(tmpdir: File.join(tmpdir, '../../../'), filename: 'name').execute } + expect { described_class.new(tmpdir: File.join(Dir.mktmpdir, 'test', '..'), filename: 'name').execute } .to raise_error(Gitlab::Utils::PathTraversalAttackError, 'Invalid path') end end diff --git a/spec/services/bulk_imports/file_decompression_service_spec.rb b/spec/services/bulk_imports/file_decompression_service_spec.rb index 4e8f78c8243..1d6aa79a37f 100644 --- a/spec/services/bulk_imports/file_decompression_service_spec.rb +++ b/spec/services/bulk_imports/file_decompression_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe BulkImports::FileDecompressionService do FileUtils.remove_entry(tmpdir) end - subject { described_class.new(dir: tmpdir, filename: gz_filename) } + subject { described_class.new(tmpdir: tmpdir, filename: gz_filename) } describe '#execute' do it 'decompresses specified file' do @@ -55,10 +55,18 @@ RSpec.describe BulkImports::FileDecompressionService do end context 'when dir is not in tmpdir' do - subject { described_class.new(dir: '/etc', filename: 'filename') } + subject { described_class.new(tmpdir: '/etc', filename: 'filename') } it 'raises an error' do - expect { subject.execute }.to raise_error(described_class::ServiceError, 'Invalid target directory') + expect { subject.execute }.to raise_error(StandardError, 'path /etc is not allowed') + end + end + + context 'when path is being traversed' do + subject { described_class.new(tmpdir: File.join(Dir.mktmpdir, 'test', '..'), filename: 'filename') } + + it 'raises an error' do + expect { subject.execute }.to raise_error(Gitlab::Utils::PathTraversalAttackError, 'Invalid path') end end @@ -69,7 +77,7 @@ RSpec.describe BulkImports::FileDecompressionService do FileUtils.ln_s(File.join(tmpdir, gz_filename), symlink) end - subject { described_class.new(dir: tmpdir, filename: 'symlink.gz') } + subject { described_class.new(tmpdir: tmpdir, filename: 'symlink.gz') } it 'raises an error and removes the file' do expect { subject.execute }.to raise_error(described_class::ServiceError, 'Invalid file') @@ -87,7 +95,7 @@ RSpec.describe BulkImports::FileDecompressionService do subject.instance_variable_set(:@decompressed_filepath, symlink) end - subject { described_class.new(dir: tmpdir, filename: gz_filename) } + subject { described_class.new(tmpdir: tmpdir, filename: gz_filename) } it 'raises an error and removes the file' do expect { subject.execute }.to raise_error(described_class::ServiceError, 'Invalid file') diff --git a/spec/services/bulk_imports/file_download_service_spec.rb b/spec/services/bulk_imports/file_download_service_spec.rb index a24af9ae64d..bd664d6e996 100644 --- a/spec/services/bulk_imports/file_download_service_spec.rb +++ b/spec/services/bulk_imports/file_download_service_spec.rb @@ -33,7 +33,7 @@ RSpec.describe BulkImports::FileDownloadService do described_class.new( configuration: config, relative_url: '/test', - dir: tmpdir, + tmpdir: tmpdir, filename: filename, file_size_limit: file_size_limit, allowed_content_types: allowed_content_types @@ -72,7 +72,7 @@ RSpec.describe BulkImports::FileDownloadService do service = described_class.new( configuration: double, relative_url: '/test', - dir: tmpdir, + tmpdir: tmpdir, filename: filename, file_size_limit: file_size_limit, allowed_content_types: allowed_content_types @@ -157,7 +157,7 @@ RSpec.describe BulkImports::FileDownloadService do described_class.new( configuration: config, relative_url: '/test', - dir: tmpdir, + tmpdir: tmpdir, filename: 'symlink', file_size_limit: file_size_limit, allowed_content_types: allowed_content_types @@ -179,7 +179,7 @@ RSpec.describe BulkImports::FileDownloadService do described_class.new( configuration: config, relative_url: '/test', - dir: '/etc', + tmpdir: '/etc', filename: filename, file_size_limit: file_size_limit, allowed_content_types: allowed_content_types @@ -188,8 +188,28 @@ RSpec.describe BulkImports::FileDownloadService do it 'raises an error' do expect { subject.execute }.to raise_error( - described_class::ServiceError, - 'Invalid target directory' + StandardError, + 'path /etc is not allowed' + ) + end + end + + context 'when dir path is being traversed' do + subject do + described_class.new( + configuration: config, + relative_url: '/test', + tmpdir: File.join(Dir.mktmpdir('bulk_imports'), 'test', '..'), + filename: filename, + file_size_limit: file_size_limit, + allowed_content_types: allowed_content_types + ) + end + + it 'raises an error' do + expect { subject.execute }.to raise_error( + Gitlab::Utils::PathTraversalAttackError, + 'Invalid path' ) end end diff --git a/spec/services/bulk_imports/file_export_service_spec.rb b/spec/services/bulk_imports/file_export_service_spec.rb index 0d129c75384..94efceff6c6 100644 --- a/spec/services/bulk_imports/file_export_service_spec.rb +++ b/spec/services/bulk_imports/file_export_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe BulkImports::FileExportService do let_it_be(:project) { create(:project) } let_it_be(:export_path) { Dir.mktmpdir } - let_it_be(:relation) { 'uploads' } + let_it_be(:relation) { BulkImports::FileTransfer::BaseConfig::UPLOADS_RELATION } subject(:service) { described_class.new(project, export_path, relation) } @@ -20,6 +20,20 @@ RSpec.describe BulkImports::FileExportService do subject.execute end + context 'when relation is lfs objects' do + let_it_be(:relation) { BulkImports::FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION } + + it 'executes lfs objects export service' do + expect_next_instance_of(BulkImports::LfsObjectsExportService) do |service| + expect(service).to receive(:execute) + end + + expect(subject).to receive(:tar_cf).with(archive: File.join(export_path, 'lfs_objects.tar'), dir: export_path) + + subject.execute + end + end + context 'when unsupported relation is passed' do it 'raises an error' do service = described_class.new(project, export_path, 'unsupported') diff --git a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb new file mode 100644 index 00000000000..5ae54ed309b --- /dev/null +++ b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::LfsObjectsExportService do + let_it_be(:project) { create(:project) } + let_it_be(:lfs_json_filename) { "#{BulkImports::FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION}.json" } + let_it_be(:remote_url) { 'http://my-object-storage.local' } + + let(:export_path) { Dir.mktmpdir } + let(:lfs_object) { create(:lfs_object, :with_file) } + + subject(:service) { described_class.new(project, export_path) } + + before do + stub_lfs_object_storage + + %w(wiki design).each do |repository_type| + create( + :lfs_objects_project, + project: project, + repository_type: repository_type, + lfs_object: lfs_object + ) + end + + project.lfs_objects << lfs_object + end + + after do + FileUtils.remove_entry(export_path) if Dir.exist?(export_path) + end + + describe '#execute' do + it 'exports lfs objects and their repository types' do + filepath = File.join(export_path, lfs_json_filename) + + service.execute + + expect(File).to exist(File.join(export_path, lfs_object.oid)) + expect(File).to exist(filepath) + + lfs_json = Gitlab::Json.parse(File.read(filepath)) + + expect(lfs_json).to eq( + { + lfs_object.oid => [ + LfsObjectsProject.repository_types['wiki'], + LfsObjectsProject.repository_types['design'], + nil + ] + } + ) + end + + context 'when lfs object is remotely stored' do + let(:lfs_object) { create(:lfs_object, :object_storage) } + + it 'downloads lfs object from object storage' do + expect_next_instance_of(LfsObjectUploader) do |instance| + expect(instance).to receive(:url).and_return(remote_url) + end + + expect(subject).to receive(:download).with(remote_url, File.join(export_path, lfs_object.oid)) + + service.execute + end + end + end +end diff --git a/spec/services/chat_names/authorize_user_service_spec.rb b/spec/services/chat_names/authorize_user_service_spec.rb index b0bb741564d..53d90c7f100 100644 --- a/spec/services/chat_names/authorize_user_service_spec.rb +++ b/spec/services/chat_names/authorize_user_service_spec.rb @@ -4,10 +4,10 @@ require 'spec_helper' RSpec.describe ChatNames::AuthorizeUserService do describe '#execute' do - subject { described_class.new(service, params) } - + let(:integration) { create(:integration) } let(:result) { subject.execute } - let(:service) { create(:service) } + + subject { described_class.new(integration, params) } context 'when all parameters are valid' do let(:params) { { team_id: 'T0001', team_domain: 'myteam', user_id: 'U0001', user_name: 'user' } } diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb index 9bbad09cd0d..4b0a1204558 100644 --- a/spec/services/chat_names/find_user_service_spec.rb +++ b/spec/services/chat_names/find_user_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state do describe '#execute' do - let(:integration) { create(:service) } + let(:integration) { create(:integration) } subject { described_class.new(integration, params).execute } diff --git a/spec/services/ci/after_requeue_job_service_spec.rb b/spec/services/ci/after_requeue_job_service_spec.rb index 2465bac7d10..d2acf3ad2f1 100644 --- a/spec/services/ci/after_requeue_job_service_spec.rb +++ b/spec/services/ci/after_requeue_job_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Ci::AfterRequeueJobService do let_it_be(:project) { create(:project) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:pipeline) { create(:ci_pipeline, project: project) } diff --git a/spec/services/ci/archive_trace_service_spec.rb b/spec/services/ci/archive_trace_service_spec.rb index b08ba6fd5e5..bf2e5302d2e 100644 --- a/spec/services/ci/archive_trace_service_spec.rb +++ b/spec/services/ci/archive_trace_service_spec.rb @@ -15,6 +15,25 @@ RSpec.describe Ci::ArchiveTraceService, '#execute' do expect(job.trace_metadata.trace_artifact).to eq(job.job_artifacts_trace) end + context 'integration hooks' do + it do + stub_feature_flags(datadog_integration_logs_collection: [job.project]) + + expect(job.project).to receive(:execute_integrations) do |data, hook_type| + expect(data).to eq Gitlab::DataBuilder::ArchiveTrace.build(job) + expect(hook_type).to eq :archive_trace_hooks + end + expect { subject }.not_to raise_error + end + + it 'with feature flag disabled' do + stub_feature_flags(datadog_integration_logs_collection: false) + + expect(job.project).not_to receive(:execute_integrations) + expect { subject }.not_to raise_error + end + end + context 'when trace is already archived' do let!(:job) { create(:ci_build, :success, :trace_artifact) } diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb index 2237fd76d07..d61abf6a6ee 100644 --- a/spec/services/ci/create_downstream_pipeline_service_spec.rb +++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb @@ -604,7 +604,7 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do context 'when configured with bridge job rules' do before do stub_ci_pipeline_yaml_file(config) - downstream_project.add_maintainer(upstream_project.owner) + downstream_project.add_maintainer(upstream_project.first_owner) end let(:config) do @@ -622,13 +622,13 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do end let(:primary_pipeline) do - Ci::CreatePipelineService.new(upstream_project, upstream_project.owner, { ref: 'master' }) + Ci::CreatePipelineService.new(upstream_project, upstream_project.first_owner, { ref: 'master' }) .execute(:push, save_on_errors: false) .payload end let(:bridge) { primary_pipeline.processables.find_by(name: 'bridge-job') } - let(:service) { described_class.new(upstream_project, upstream_project.owner) } + let(:service) { described_class.new(upstream_project, upstream_project.first_owner) } context 'that include the bridge job' do it 'creates the downstream pipeline' do diff --git a/spec/services/ci/create_pipeline_service/cache_spec.rb b/spec/services/ci/create_pipeline_service/cache_spec.rb index f5f162e4578..ca85a8cce64 100644 --- a/spec/services/ci/create_pipeline_service/cache_spec.rb +++ b/spec/services/ci/create_pipeline_service/cache_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do context 'cache' do let(:project) { create(:project, :custom_repo, files: files) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:source) { :push } let(:service) { described_class.new(project, user, { ref: ref }) } diff --git a/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb b/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb index c69c91593ae..e62a94b6df8 100644 --- a/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb +++ b/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do describe 'creation errors and warnings' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:source) { :push } diff --git a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb index f150a4f8b51..a0cbf14d936 100644 --- a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb +++ b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:service) { described_class.new(project, user, { ref: ref }) } diff --git a/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb b/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb index 026111d59f1..716a929830e 100644 --- a/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb +++ b/spec/services/ci/create_pipeline_service/custom_yaml_tags_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do describe '!reference tags' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:source) { :push } diff --git a/spec/services/ci/create_pipeline_service/dry_run_spec.rb b/spec/services/ci/create_pipeline_service/dry_run_spec.rb index ae43c63b516..9a7e97fb12b 100644 --- a/spec/services/ci/create_pipeline_service/dry_run_spec.rb +++ b/spec/services/ci/create_pipeline_service/dry_run_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:service) { described_class.new(project, user, { ref: ref }) } diff --git a/spec/services/ci/create_pipeline_service/include_spec.rb b/spec/services/ci/create_pipeline_service/include_spec.rb index aa01977272a..3116801d50c 100644 --- a/spec/services/ci/create_pipeline_service/include_spec.rb +++ b/spec/services/ci/create_pipeline_service/include_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do context 'include:' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:variables_attributes) { [{ key: 'MYVAR', secret_value: 'hello' }] } diff --git a/spec/services/ci/create_pipeline_service/logger_spec.rb b/spec/services/ci/create_pipeline_service/logger_spec.rb index dfe0859015d..53e5f0dd7f2 100644 --- a/spec/services/ci/create_pipeline_service/logger_spec.rb +++ b/spec/services/ci/create_pipeline_service/logger_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do context 'pipeline logger' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:service) { described_class.new(project, user, { ref: ref }) } @@ -35,7 +35,10 @@ RSpec.describe Ci::CreatePipelineService do 'pipeline_creation_service_duration_s' => a_kind_of(Numeric), 'pipeline_creation_duration_s' => counters, 'pipeline_size_count' => counters, - 'pipeline_step_gitlab_ci_pipeline_chain_seed_duration_s' => counters + 'pipeline_step_gitlab_ci_pipeline_chain_seed_duration_s' => counters, + 'pipeline_seed_build_inclusion_duration_s' => counters, + 'pipeline_builds_tags_count' => a_kind_of(Numeric), + 'pipeline_builds_distinct_tags_count' => a_kind_of(Numeric) } end @@ -81,7 +84,6 @@ RSpec.describe Ci::CreatePipelineService do { 'pipeline_creation_caller' => 'Ci::CreatePipelineService', 'pipeline_source' => 'push', - 'pipeline_id' => nil, 'pipeline_persisted' => false, 'project_id' => project.id, 'pipeline_creation_service_duration_s' => a_kind_of(Numeric), diff --git a/spec/services/ci/create_pipeline_service/merge_requests_spec.rb b/spec/services/ci/create_pipeline_service/merge_requests_spec.rb index a1f85faa69f..de19ef363fb 100644 --- a/spec/services/ci/create_pipeline_service/merge_requests_spec.rb +++ b/spec/services/ci/create_pipeline_service/merge_requests_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do context 'merge requests handling' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/feature' } let(:source) { :push } diff --git a/spec/services/ci/create_pipeline_service/needs_spec.rb b/spec/services/ci/create_pipeline_service/needs_spec.rb index 9070d86f7f6..abd17ccdd6a 100644 --- a/spec/services/ci/create_pipeline_service/needs_spec.rb +++ b/spec/services/ci/create_pipeline_service/needs_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do context 'needs' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:source) { :push } diff --git a/spec/services/ci/create_pipeline_service/parallel_spec.rb b/spec/services/ci/create_pipeline_service/parallel_spec.rb index 6b455bf4874..ae28b74fef5 100644 --- a/spec/services/ci/create_pipeline_service/parallel_spec.rb +++ b/spec/services/ci/create_pipeline_service/parallel_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:service) { described_class.new(project, user, { ref: 'master' }) } let(:pipeline) { service.execute(:push).payload } diff --git a/spec/services/ci/create_pipeline_service/parameter_content_spec.rb b/spec/services/ci/create_pipeline_service/parameter_content_spec.rb index 761504ffb58..c28bc9d8c13 100644 --- a/spec/services/ci/create_pipeline_service/parameter_content_spec.rb +++ b/spec/services/ci/create_pipeline_service/parameter_content_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:service) { described_class.new(project, user, { ref: 'refs/heads/master' }) } let(:content) do diff --git a/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb b/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb index 5e34eeb99db..c6e69862422 100644 --- a/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb +++ b/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do describe '.pre/.post stages' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:source) { :push } let(:service) { described_class.new(project, user, { ref: ref }) } diff --git a/spec/services/ci/create_pipeline_service/rules_spec.rb b/spec/services/ci/create_pipeline_service/rules_spec.rb index d0915f099de..d0ce1c5aba8 100644 --- a/spec/services/ci/create_pipeline_service/rules_spec.rb +++ b/spec/services/ci/create_pipeline_service/rules_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do let(:project) { create(:project, :repository) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:source) { :push } let(:service) { described_class.new(project, user, { ref: ref }) } diff --git a/spec/services/ci/create_pipeline_service/tags_spec.rb b/spec/services/ci/create_pipeline_service/tags_spec.rb index cbbeb870c5f..61c2415fa33 100644 --- a/spec/services/ci/create_pipeline_service/tags_spec.rb +++ b/spec/services/ci/create_pipeline_service/tags_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Ci::CreatePipelineService do describe 'tags:' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let(:ref) { 'refs/heads/master' } let(:service) { described_class.new(project, user, { ref: ref }) } diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index ef879d536c3..a7026f5062e 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Ci::CreatePipelineService do include ProjectForksHelper let_it_be_with_refind(:project) { create(:project, :repository) } - let_it_be_with_reload(:user) { project.owner } + let_it_be_with_reload(:user) { project.first_owner } let(:ref_name) { 'refs/heads/master' } @@ -146,140 +146,22 @@ RSpec.describe Ci::CreatePipelineService do end context 'when merge requests already exist for this source branch' do - let(:merge_request_1) do + let!(:merge_request_1) do create(:merge_request, source_branch: 'feature', target_branch: "master", source_project: project) end - let(:merge_request_2) do + let!(:merge_request_2) do create(:merge_request, source_branch: 'feature', target_branch: "v1.1.0", source_project: project) end - context 'when related merge request is already merged' do - let!(:merged_merge_request) do - create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project, state: 'merged') - end - - it 'does not schedule update head pipeline job' do - expect(UpdateHeadPipelineForMergeRequestWorker).not_to receive(:perform_async).with(merged_merge_request.id) - - execute_service - end - end - context 'when the head pipeline sha equals merge request sha' do it 'updates head pipeline of each merge request', :sidekiq_might_not_need_inline do - merge_request_1 - merge_request_2 - head_pipeline = execute_service(ref: 'feature', after: nil).payload expect(merge_request_1.reload.head_pipeline).to eq(head_pipeline) expect(merge_request_2.reload.head_pipeline).to eq(head_pipeline) end end - - context 'when the head pipeline sha does not equal merge request sha' do - it 'does not update the head piepeline of MRs' do - merge_request_1 - merge_request_2 - - allow_any_instance_of(Ci::Pipeline).to receive(:latest?).and_return(true) - - expect { execute_service(after: 'ae73cb07c9eeaf35924a10f713b364d32b2dd34f') }.not_to raise_error - - last_pipeline = Ci::Pipeline.last - - expect(merge_request_1.reload.head_pipeline).not_to eq(last_pipeline) - expect(merge_request_2.reload.head_pipeline).not_to eq(last_pipeline) - end - end - - context 'when there is no pipeline for source branch' do - it "does not update merge request head pipeline" do - merge_request = create(:merge_request, source_branch: 'feature', - target_branch: "branch_1", - source_project: project) - - head_pipeline = execute_service.payload - - expect(merge_request.reload.head_pipeline).not_to eq(head_pipeline) - end - end - - context 'when merge request target project is different from source project' do - let!(:project) { fork_project(target_project, nil, repository: true) } - let!(:target_project) { create(:project, :repository) } - let!(:user) { create(:user) } - - before do - project.add_developer(user) - end - - it 'updates head pipeline for merge request', :sidekiq_might_not_need_inline do - merge_request = create(:merge_request, source_branch: 'feature', - target_branch: "master", - source_project: project, - target_project: target_project) - - head_pipeline = execute_service(ref: 'feature', after: nil).payload - - expect(merge_request.reload.head_pipeline).to eq(head_pipeline) - end - end - - context 'when the pipeline is not the latest for the branch' do - it 'does not update merge request head pipeline' do - merge_request = create(:merge_request, source_branch: 'master', - target_branch: "branch_1", - source_project: project) - - allow_any_instance_of(MergeRequest) - .to receive(:find_actual_head_pipeline) { } - - execute_service - - expect(merge_request.reload.head_pipeline).to be_nil - end - end - - context 'when pipeline has errors' do - before do - stub_ci_pipeline_yaml_file('some invalid syntax') - end - - it 'updates merge request head pipeline reference', :sidekiq_might_not_need_inline do - merge_request = create(:merge_request, source_branch: 'master', - target_branch: 'feature', - source_project: project) - - head_pipeline = execute_service.payload - - expect(head_pipeline).to be_persisted - expect(head_pipeline.yaml_errors).to be_present - expect(head_pipeline.messages).to be_present - expect(merge_request.reload.head_pipeline).to eq head_pipeline - end - end - - context 'when pipeline has been skipped' do - before do - allow_any_instance_of(Ci::Pipeline) - .to receive(:git_commit_message) - .and_return('some commit [ci skip]') - end - - it 'updates merge request head pipeline', :sidekiq_might_not_need_inline do - merge_request = create(:merge_request, source_branch: 'master', - target_branch: 'feature', - source_project: project) - - head_pipeline = execute_service.payload - - expect(head_pipeline).to be_skipped - expect(head_pipeline).to be_persisted - expect(merge_request.reload.head_pipeline).to eq head_pipeline - end - end end context 'auto-cancel enabled' do @@ -1655,7 +1537,7 @@ RSpec.describe Ci::CreatePipelineService do expect(pipeline.target_sha).to be_nil end - it 'schedules update for the head pipeline of the merge request' do + it 'schedules update for the head pipeline of the merge request', :sidekiq_inline do expect(UpdateHeadPipelineForMergeRequestWorker) .to receive(:perform_async).with(merge_request.id) diff --git a/spec/services/ci/destroy_pipeline_service_spec.rb b/spec/services/ci/destroy_pipeline_service_spec.rb index 6c1c02b2875..045051c7152 100644 --- a/spec/services/ci/destroy_pipeline_service_spec.rb +++ b/spec/services/ci/destroy_pipeline_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe ::Ci::DestroyPipelineService do subject { described_class.new(project, user).execute(pipeline) } context 'user is owner' do - let(:user) { project.owner } + let(:user) { project.first_owner } it 'destroys the pipeline' do subject @@ -66,6 +66,28 @@ RSpec.describe ::Ci::DestroyPipelineService do expect { subject }.to change { Ci::DeletedObject.count } end end + + context 'when job has trace chunks' do + let(:connection_params) { Gitlab.config.artifacts.object_store.connection.symbolize_keys } + let(:connection) { ::Fog::Storage.new(connection_params) } + + before do + stub_object_storage(connection_params: connection_params, remote_directory: 'artifacts') + stub_artifacts_object_storage + end + + let!(:trace_chunk) { create(:ci_build_trace_chunk, :fog_with_data, build: build) } + + it 'destroys associated trace chunks' do + subject + + expect { trace_chunk.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'removes data from object store' do + expect { subject }.to change { Ci::BuildTraceChunks::Fog.new.data(trace_chunk) } + end + end end context 'when pipeline is in cancelable state' do diff --git a/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb b/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb new file mode 100644 index 00000000000..74fa42962f3 --- /dev/null +++ b/spec/services/ci/job_artifacts/delete_project_artifacts_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobArtifacts::DeleteProjectArtifactsService do + let_it_be(:project) { create(:project) } + + subject { described_class.new(project: project) } + + describe '#execute' do + it 'enqueues a Ci::ExpireProjectBuildArtifactsWorker' do + expect(Ci::JobArtifacts::ExpireProjectBuildArtifactsWorker).to receive(:perform_async).with(project.id).and_call_original + + subject.execute + end + end +end diff --git a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb index e71f1a4266a..e95a449d615 100644 --- a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb +++ b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s context 'with preloaded relationships' do before do - stub_const("#{described_class}::LOOP_LIMIT", 1) + stub_const("#{described_class}::LARGE_LOOP_LIMIT", 1) end context 'with ci_destroy_unlocked_job_artifacts feature flag disabled' do @@ -53,46 +53,6 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s log = ActiveRecord::QueryRecorder.new { subject } expect(log.count).to be_within(1).of(8) end - - context 'with several locked-unknown artifact records' do - before do - stub_const("#{described_class}::LOOP_LIMIT", 10) - stub_const("#{described_class}::BATCH_SIZE", 2) - end - - let!(:lockable_artifact_records) do - [ - create(:ci_job_artifact, :metadata, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: locked_job), - create(:ci_job_artifact, :junit, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: locked_job), - create(:ci_job_artifact, :sast, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: locked_job), - create(:ci_job_artifact, :cobertura, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: locked_job), - create(:ci_job_artifact, :trace, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: locked_job) - ] - end - - let!(:unlockable_artifact_records) do - [ - create(:ci_job_artifact, :metadata, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: job), - create(:ci_job_artifact, :junit, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: job), - create(:ci_job_artifact, :sast, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: job), - create(:ci_job_artifact, :cobertura, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: job), - create(:ci_job_artifact, :trace, :expired, locked: ::Ci::JobArtifact.lockeds[:unknown], job: job), - artifact - ] - end - - it 'updates the locked status of job artifacts from artifacts-locked pipelines' do - subject - - expect(lockable_artifact_records).to be_all(&:persisted?) - expect(lockable_artifact_records).to be_all { |artifact| artifact.reload.artifact_artifacts_locked? } - end - - it 'unlocks and then destroys job artifacts from artifacts-unlocked pipelines' do - expect { subject }.to change { Ci::JobArtifact.count }.by(-6) - expect(Ci::JobArtifact.where(id: unlockable_artifact_records.map(&:id))).to be_empty - end - end end end @@ -159,7 +119,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) } before do - stub_const("#{described_class}::LOOP_LIMIT", 10) + stub_const("#{described_class}::LARGE_LOOP_LIMIT", 10) end context 'when the import fails' do @@ -229,7 +189,8 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s context 'when loop reached loop limit' do before do - stub_const("#{described_class}::LOOP_LIMIT", 1) + stub_feature_flags(ci_artifact_fast_removal_large_loop_limit: false) + stub_const("#{described_class}::SMALL_LOOP_LIMIT", 1) end it 'destroys one artifact' do diff --git a/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb b/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb new file mode 100644 index 00000000000..fb9dd6b876b --- /dev/null +++ b/spec/services/ci/job_artifacts/expire_project_build_artifacts_service_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobArtifacts::ExpireProjectBuildArtifactsService do + let_it_be(:project) { create(:project) } + let_it_be(:pipeline, reload: true) { create(:ci_pipeline, :unlocked, project: project) } + + let(:expiry_time) { Time.current } + + RSpec::Matchers.define :have_locked_status do |expected_status| + match do |job_artifacts| + predicate = "#{expected_status}?".to_sym + job_artifacts.all? { |artifact| artifact.__send__(predicate) } + end + end + + RSpec::Matchers.define :expire_at do |expected_expiry| + match do |job_artifacts| + job_artifacts.all? { |artifact| artifact.expire_at.to_i == expected_expiry.to_i } + end + end + + RSpec::Matchers.define :have_no_expiry do + match do |job_artifacts| + job_artifacts.all? { |artifact| artifact.expire_at.nil? } + end + end + + describe '#execute' do + subject(:execute) { described_class.new(project.id, expiry_time).execute } + + context 'with job containing erasable artifacts' do + let_it_be(:job, reload: true) { create(:ci_build, :erasable, pipeline: pipeline) } + + it 'unlocks erasable job artifacts' do + execute + + expect(job.job_artifacts).to have_locked_status(:artifact_unlocked) + end + + it 'expires erasable job artifacts' do + execute + + expect(job.job_artifacts).to expire_at(expiry_time) + end + end + + context 'with job containing trace artifacts' do + let_it_be(:job, reload: true) { create(:ci_build, :trace_artifact, pipeline: pipeline) } + + it 'does not unlock trace artifacts' do + execute + + expect(job.job_artifacts).to have_locked_status(:artifact_unknown) + end + + it 'does not expire trace artifacts' do + execute + + expect(job.job_artifacts).to have_no_expiry + end + end + + context 'with job from artifact locked pipeline' do + let_it_be(:job, reload: true) { create(:ci_build, pipeline: pipeline) } + let_it_be(:locked_artifact, reload: true) { create(:ci_job_artifact, :locked, job: job) } + + before do + pipeline.artifacts_locked! + end + + it 'does not unlock locked artifacts' do + execute + + expect(job.job_artifacts).to have_locked_status(:artifact_artifacts_locked) + end + + it 'does not expire locked artifacts' do + execute + + expect(job.job_artifacts).to have_no_expiry + end + end + + context 'with job containing both erasable and trace artifacts' do + let_it_be(:job, reload: true) { create(:ci_build, pipeline: pipeline) } + let_it_be(:erasable_artifact, reload: true) { create(:ci_job_artifact, :archive, job: job) } + let_it_be(:trace_artifact, reload: true) { create(:ci_job_artifact, :trace, job: job) } + + it 'unlocks erasable artifacts' do + execute + + expect(erasable_artifact.artifact_unlocked?).to be_truthy + end + + it 'expires erasable artifacts' do + execute + + expect(erasable_artifact.expire_at.to_i).to eq(expiry_time.to_i) + end + + it 'does not unlock trace artifacts' do + execute + + expect(trace_artifact.artifact_unlocked?).to be_falsey + end + + it 'does not expire trace artifacts' do + execute + + expect(trace_artifact.expire_at).to be_nil + end + end + + context 'with multiple pipelines' do + let_it_be(:job, reload: true) { create(:ci_build, :erasable, pipeline: pipeline) } + + let_it_be(:pipeline2, reload: true) { create(:ci_pipeline, :unlocked, project: project) } + let_it_be(:job2, reload: true) { create(:ci_build, :erasable, pipeline: pipeline) } + + it 'unlocks artifacts across pipelines' do + execute + + expect(job.job_artifacts).to have_locked_status(:artifact_unlocked) + expect(job2.job_artifacts).to have_locked_status(:artifact_unlocked) + end + + it 'expires artifacts across pipelines' do + execute + + expect(job.job_artifacts).to expire_at(expiry_time) + expect(job2.job_artifacts).to expire_at(expiry_time) + end + end + + context 'with artifacts belonging to another project' do + let_it_be(:job, reload: true) { create(:ci_build, :erasable, pipeline: pipeline) } + + let_it_be(:another_project, reload: true) { create(:project) } + let_it_be(:another_pipeline, reload: true) { create(:ci_pipeline, project: another_project) } + let_it_be(:another_job, reload: true) { create(:ci_build, :erasable, pipeline: another_pipeline) } + + it 'does not unlock erasable artifacts in other projects' do + execute + + expect(another_job.job_artifacts).to have_locked_status(:artifact_unknown) + end + + it 'does not expire erasable artifacts in other projects' do + execute + + expect(another_job.job_artifacts).to have_no_expiry + end + end + end +end diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb index 31e1b0a896d..26bc6f747e1 100644 --- a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb +++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Ci::PipelineProcessing::AtomicProcessingService do describe 'Pipeline Processing Service Tests With Yaml' do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } where(:test_file_path) do Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml')) @@ -65,7 +65,7 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService do describe 'Pipeline Processing Service' do let(:project) { create(:project, :repository) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:pipeline) do create(:ci_empty_pipeline, ref: 'master', project: project) diff --git a/spec/services/ci/pipelines/add_job_service_spec.rb b/spec/services/ci/pipelines/add_job_service_spec.rb index 709a840c644..560724a1c6a 100644 --- a/spec/services/ci/pipelines/add_job_service_spec.rb +++ b/spec/services/ci/pipelines/add_job_service_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Ci::Pipelines::AddJobService do execute end.to change { job.slice(:pipeline, :project, :ref) }.to( pipeline: pipeline, project: pipeline.project, ref: pipeline.ref - ) + ).and change { job.metadata.project }.to(pipeline.project) end it 'returns a service response with the job as payload' do diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb index 34f77260334..85ef8b60af4 100644 --- a/spec/services/ci/play_build_service_spec.rb +++ b/spec/services/ci/play_build_service_spec.rb @@ -122,7 +122,7 @@ RSpec.describe Ci::PlayBuildService, '#execute' do end context 'when build is not a playable manual action' do - let(:build) { create(:ci_build, when: :manual, pipeline: pipeline) } + let(:build) { create(:ci_build, :success, pipeline: pipeline) } let!(:branch) { create(:protected_branch, :developers_can_merge, name: build.ref, project: project) } it 'duplicates the build' do @@ -138,6 +138,18 @@ RSpec.describe Ci::PlayBuildService, '#execute' do expect(build.user).not_to eq user expect(duplicate.user).to eq user end + + context 'and is not retryable' do + let(:build) { create(:ci_build, :deployment_rejected, pipeline: pipeline) } + + it 'does not duplicate the build' do + expect { service.execute(build) }.not_to change { Ci::Build.count } + end + + it 'does not enqueue the build' do + expect { service.execute(build) }.not_to change { build.status } + end + end end context 'when build is not action' do diff --git a/spec/services/ci/process_sync_events_service_spec.rb b/spec/services/ci/process_sync_events_service_spec.rb index 00b670ff54f..8b7717fe4bf 100644 --- a/spec/services/ci/process_sync_events_service_spec.rb +++ b/spec/services/ci/process_sync_events_service_spec.rb @@ -28,10 +28,10 @@ RSpec.describe Ci::ProcessSyncEventsService do it 'consumes events' do expect { execute }.to change(Projects::SyncEvent, :count).from(2).to(0) - expect(project1.ci_project_mirror).to have_attributes( + expect(project1.reload.ci_project_mirror).to have_attributes( namespace_id: parent_group_1.id ) - expect(project2.ci_project_mirror).to have_attributes( + expect(project2.reload.ci_project_mirror).to have_attributes( namespace_id: parent_group_2.id ) end @@ -71,6 +71,24 @@ RSpec.describe Ci::ProcessSyncEventsService do expect { execute }.not_to change(Projects::SyncEvent, :count) end end + + it 'does not delete non-executed events' do + new_project = create(:project) + sync_event_class.delete_all + + project1.update!(group: parent_group_2) + new_project.update!(group: parent_group_1) + project2.update!(group: parent_group_1) + + new_project_sync_event = new_project.sync_events.last + + allow(sync_event_class).to receive(:preload_synced_relation).and_return( + sync_event_class.where.not(id: new_project_sync_event) + ) + + expect { execute }.to change(Projects::SyncEvent, :count).from(3).to(1) + expect(new_project_sync_event.reload).to be_persisted + end end context 'for Namespaces::SyncEvent' do @@ -88,10 +106,10 @@ RSpec.describe Ci::ProcessSyncEventsService do it 'consumes events' do expect { execute }.to change(Namespaces::SyncEvent, :count).from(2).to(0) - expect(group.ci_namespace_mirror).to have_attributes( + expect(group.reload.ci_namespace_mirror).to have_attributes( traversal_ids: [parent_group_1.id, parent_group_2.id, group.id] ) - expect(parent_group_2.ci_namespace_mirror).to have_attributes( + expect(parent_group_2.reload.ci_namespace_mirror).to have_attributes( traversal_ids: [parent_group_1.id, parent_group_2.id] ) end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 866015aa523..251159864f5 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -827,11 +827,17 @@ module Ci end context 'when project already has running jobs' do - let!(:build2) { create(:ci_build, :running, pipeline: pipeline, runner: shared_runner) } - let!(:build3) { create(:ci_build, :running, pipeline: pipeline, runner: shared_runner) } + let(:build2) { create(:ci_build, :running, pipeline: pipeline, runner: shared_runner) } + let(:build3) { create(:ci_build, :running, pipeline: pipeline, runner: shared_runner) } + + before do + ::Ci::RunningBuild.upsert_shared_runner_build!(build2) + ::Ci::RunningBuild.upsert_shared_runner_build!(build3) + end it 'counts job queuing time histogram with expected labels' do allow(attempt_counter).to receive(:increment) + expect(job_queue_duration_seconds).to receive(:observe) .with({ shared_runner: expected_shared_runner, jobs_running_for_project: expected_jobs_running_for_project_third_job, @@ -845,6 +851,14 @@ module Ci shared_examples 'metrics collector' do it_behaves_like 'attempt counter collector' it_behaves_like 'jobs queueing time histogram collector' + + context 'when using denormalized data is disabled' do + before do + stub_feature_flags(ci_pending_builds_maintain_denormalized_data: false) + end + + it_behaves_like 'jobs queueing time histogram collector' + end end context 'when shared runner is used' do @@ -875,6 +889,16 @@ module Ci it_behaves_like 'metrics collector' end + context 'when max running jobs bucket size is exceeded' do + before do + stub_const('Gitlab::Ci::Queue::Metrics::JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET', 1) + end + + let(:expected_jobs_running_for_project_third_job) { '1+' } + + it_behaves_like 'metrics collector' + end + context 'when pending job with queued_at=nil is used' do before do pending_job.update!(queued_at: nil) diff --git a/spec/services/ci/register_runner_service_spec.rb b/spec/services/ci/register_runner_service_spec.rb new file mode 100644 index 00000000000..e813a1d8b31 --- /dev/null +++ b/spec/services/ci/register_runner_service_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ci::RegisterRunnerService do + let(:registration_token) { 'abcdefg123456' } + + before do + stub_feature_flags(runner_registration_control: false) + stub_application_setting(runners_registration_token: registration_token) + stub_application_setting(valid_runner_registrars: ApplicationSetting::VALID_RUNNER_REGISTRAR_TYPES) + end + + describe '#execute' do + let(:token) { } + let(:args) { {} } + + subject { described_class.new.execute(token, args) } + + context 'when no token is provided' do + let(:token) { '' } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when invalid token is provided' do + let(:token) { 'invalid' } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when valid token is provided' do + context 'with a registration token' do + let(:token) { registration_token } + + it 'creates runner with default values' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.persisted?).to be_truthy + expect(subject.run_untagged).to be true + expect(subject.active).to be true + expect(subject.token).not_to eq(registration_token) + expect(subject).to be_instance_type + end + + context 'with non-default arguments' do + let(:args) do + { + description: 'some description', + active: false, + locked: true, + run_untagged: false, + tag_list: %w(tag1 tag2), + access_level: 'ref_protected', + maximum_timeout: 600, + name: 'some name', + version: 'some version', + revision: 'some revision', + platform: 'some platform', + architecture: 'some architecture', + ip_address: '10.0.0.1', + config: { + gpus: 'some gpu config' + } + } + end + + it 'creates runner with specified values', :aggregate_failures do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.active).to eq args[:active] + expect(subject.locked).to eq args[:locked] + expect(subject.run_untagged).to eq args[:run_untagged] + expect(subject.tags).to contain_exactly( + an_object_having_attributes(name: 'tag1'), + an_object_having_attributes(name: 'tag2') + ) + expect(subject.access_level).to eq args[:access_level] + expect(subject.maximum_timeout).to eq args[:maximum_timeout] + expect(subject.name).to eq args[:name] + expect(subject.version).to eq args[:version] + expect(subject.revision).to eq args[:revision] + expect(subject.platform).to eq args[:platform] + expect(subject.architecture).to eq args[:architecture] + expect(subject.ip_address).to eq args[:ip_address] + end + end + end + + context 'when project token is used' do + let(:project) { create(:project) } + let(:token) { project.runners_token } + + it 'creates project runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(project.runners.size).to eq(1) + is_expected.to eq(project.runners.first) + expect(subject.token).not_to eq(registration_token) + expect(subject.token).not_to eq(project.runners_token) + expect(subject).to be_project_type + end + + context 'when it exceeds the application limits' do + before do + create(:ci_runner, runner_type: :project_type, projects: [project], contacted_at: 1.second.ago) + create(:plan_limits, :default_plan, ci_registered_project_runners: 1) + end + + it 'does not create runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.persisted?).to be_falsey + expect(subject.errors.messages).to eq('runner_projects.base': ['Maximum number of ci registered project runners (1) exceeded']) + expect(project.runners.reload.size).to eq(1) + end + end + + context 'when abandoned runners cause application limits to not be exceeded' do + before do + create(:ci_runner, runner_type: :project_type, projects: [project], created_at: 14.months.ago, contacted_at: 13.months.ago) + create(:plan_limits, :default_plan, ci_registered_project_runners: 1) + end + + it 'creates runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.errors).to be_empty + expect(project.runners.reload.size).to eq(2) + expect(project.runners.recent.size).to eq(1) + end + end + + context 'when valid runner registrars do not include project' do + before do + stub_application_setting(valid_runner_registrars: ['group']) + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(runner_registration_control: true) + end + + it 'returns 403 error' do + is_expected.to be_nil + end + end + + context 'when feature flag is disabled' do + it 'registers the runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.errors).to be_empty + expect(subject.active).to be true + end + end + end + end + + context 'when group token is used' do + let(:group) { create(:group) } + let(:token) { group.runners_token } + + it 'creates a group runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.errors).to be_empty + expect(group.runners.reload.size).to eq(1) + expect(subject.token).not_to eq(registration_token) + expect(subject.token).not_to eq(group.runners_token) + expect(subject).to be_group_type + end + + context 'when it exceeds the application limits' do + before do + create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 1.month.ago) + create(:plan_limits, :default_plan, ci_registered_group_runners: 1) + end + + it 'does not create runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.persisted?).to be_falsey + expect(subject.errors.messages).to eq('runner_namespaces.base': ['Maximum number of ci registered group runners (1) exceeded']) + expect(group.runners.reload.size).to eq(1) + end + end + + context 'when abandoned runners cause application limits to not be exceeded' do + before do + create(:ci_runner, runner_type: :group_type, groups: [group], created_at: 4.months.ago, contacted_at: 3.months.ago) + create(:ci_runner, runner_type: :group_type, groups: [group], contacted_at: nil, created_at: 4.months.ago) + create(:plan_limits, :default_plan, ci_registered_group_runners: 1) + end + + it 'creates runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.errors).to be_empty + expect(group.runners.reload.size).to eq(3) + expect(group.runners.recent.size).to eq(1) + end + end + + context 'when valid runner registrars do not include group' do + before do + stub_application_setting(valid_runner_registrars: ['project']) + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(runner_registration_control: true) + end + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when feature flag is disabled' do + it 'registers the runner' do + is_expected.to be_an_instance_of(::Ci::Runner) + expect(subject.errors).to be_empty + expect(subject.active).to be true + end + end + end + end + end + end +end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 5d56084faa8..4e8e41ca6e6 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -188,13 +188,6 @@ RSpec.describe Ci::RetryBuildService do expect(new_build).to be_pending end - it 'resolves todos for old build that failed' do - expect(::MergeRequests::AddTodoWhenBuildFailsService) - .to receive_message_chain(:new, :close) - - service.execute(build) - end - context 'when there are subsequent processables that are skipped' do let!(:subsequent_build) do create(:ci_build, :skipped, stage_idx: 2, @@ -272,6 +265,17 @@ RSpec.describe Ci::RetryBuildService do expect(bridge.reload).to be_pending end end + + context 'when there is a failed job todo for the MR' do + let!(:merge_request) { create(:merge_request, source_project: project, author: user, head_pipeline: pipeline) } + let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: user, target: merge_request) } + + it 'resolves the todo for the old failed build' do + expect do + service.execute(build) + end.to change { todo.reload.state }.from('pending').to('done') + end + end end context 'when user does not have ability to execute build' do @@ -367,6 +371,14 @@ RSpec.describe Ci::RetryBuildService do it_behaves_like 'when build with dynamic environment is retried' context 'when create_deployment_in_separate_transaction feature flag is disabled' do + let(:new_build) do + travel_to(1.second.from_now) do + ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/345668') do + service.clone!(build) + end + end + end + before do stub_feature_flags(create_deployment_in_separate_transaction: false) end diff --git a/spec/services/clusters/agent_tokens/track_usage_service_spec.rb b/spec/services/clusters/agent_tokens/track_usage_service_spec.rb new file mode 100644 index 00000000000..3350b15a5ce --- /dev/null +++ b/spec/services/clusters/agent_tokens/track_usage_service_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentTokens::TrackUsageService do + let_it_be(:agent) { create(:cluster_agent) } + + describe '#execute', :clean_gitlab_redis_cache do + let(:agent_token) { create(:cluster_agent_token, agent: agent) } + + subject { described_class.new(agent_token).execute } + + context 'when last_used_at was updated recently' do + before do + agent_token.update!(last_used_at: 10.minutes.ago) + end + + it 'updates cache but not database' do + expect { subject }.not_to change { agent_token.reload.read_attribute(:last_used_at) } + + expect_redis_update + end + end + + context 'when last_used_at was not updated recently' do + it 'updates cache and database' do + does_db_update + expect_redis_update + end + + context 'with invalid token' do + before do + agent_token.description = SecureRandom.hex(2000) + end + + it 'still updates caches and database' do + expect(agent_token).to be_invalid + + does_db_update + expect_redis_update + end + end + + context 'agent is not connected' do + before do + allow(agent).to receive(:connected?).and_return(false) + end + + it 'creates an activity event' do + expect { subject }.to change { agent.activity_events.count } + + event = agent.activity_events.last + expect(event).to have_attributes( + kind: 'agent_connected', + level: 'info', + recorded_at: agent_token.reload.read_attribute(:last_used_at), + agent_token: agent_token + ) + end + end + + context 'agent is connected' do + before do + allow(agent).to receive(:connected?).and_return(true) + end + + it 'does not create an activity event' do + expect { subject }.not_to change { agent.activity_events.count } + end + end + end + + def expect_redis_update + Gitlab::Redis::Cache.with do |redis| + redis_key = "cache:#{agent_token.class}:#{agent_token.id}:attributes" + expect(redis.get(redis_key)).to be_present + end + end + + def does_db_update + expect { subject }.to change { agent_token.reload.read_attribute(:last_used_at) } + end + end +end diff --git a/spec/services/clusters/agents/create_activity_event_service_spec.rb b/spec/services/clusters/agents/create_activity_event_service_spec.rb new file mode 100644 index 00000000000..7a8f0e16d60 --- /dev/null +++ b/spec/services/clusters/agents/create_activity_event_service_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::CreateActivityEventService do + let_it_be(:agent) { create(:cluster_agent) } + let_it_be(:token) { create(:cluster_agent_token, agent: agent) } + let_it_be(:user) { create(:user) } + + describe '#execute' do + let(:params) do + { + kind: :token_created, + level: :info, + recorded_at: token.created_at, + user: user, + agent_token: token + } + end + + subject { described_class.new(agent, **params).execute } + + it 'creates an activity event record' do + expect { subject }.to change(agent.activity_events, :count).from(0).to(1) + + event = agent.activity_events.last + + expect(event).to have_attributes( + kind: 'token_created', + level: 'info', + recorded_at: token.reload.created_at, + user: user, + agent_token_id: token.id + ) + end + + it 'schedules the cleanup worker' do + expect(Clusters::Agents::DeleteExpiredEventsWorker).to receive(:perform_at) + .with(1.hour.from_now.change(min: agent.id % 60), agent.id) + + subject + end + end +end diff --git a/spec/services/clusters/agents/delete_expired_events_service_spec.rb b/spec/services/clusters/agents/delete_expired_events_service_spec.rb new file mode 100644 index 00000000000..3dc166f54eb --- /dev/null +++ b/spec/services/clusters/agents/delete_expired_events_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::DeleteExpiredEventsService do + let_it_be(:agent) { create(:cluster_agent) } + + describe '#execute' do + let_it_be(:event1) { create(:agent_activity_event, agent: agent, recorded_at: 1.hour.ago) } + let_it_be(:event2) { create(:agent_activity_event, agent: agent, recorded_at: 2.hours.ago) } + let_it_be(:event3) { create(:agent_activity_event, agent: agent, recorded_at: 3.hours.ago) } + let_it_be(:event4) { create(:agent_activity_event, agent: agent, recorded_at: 4.hours.ago) } + let_it_be(:event5) { create(:agent_activity_event, agent: agent, recorded_at: 5.hours.ago) } + + let(:deletion_cutoff) { 1.day.ago } + + subject { described_class.new(agent).execute } + + before do + allow(agent).to receive(:activity_event_deletion_cutoff).and_return(deletion_cutoff) + end + + it 'does not delete events if the limit has not been reached' do + expect { subject }.not_to change(agent.activity_events, :count) + end + + context 'there are more events than the limit' do + let(:deletion_cutoff) { event3.recorded_at } + + it 'removes events to remain at the limit, keeping the most recent' do + expect { subject }.to change(agent.activity_events, :count).from(5).to(3) + expect(agent.activity_events).to contain_exactly(event1, event2, event3) + end + end + end +end diff --git a/spec/services/clusters/integrations/create_service_spec.rb b/spec/services/clusters/integrations/create_service_spec.rb index 14653236ab1..6dac97ebf8f 100644 --- a/spec/services/clusters/integrations/create_service_spec.rb +++ b/spec/services/clusters/integrations/create_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Clusters::Integrations::CreateService, '#execute' do let_it_be_with_reload(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } let(:service) do - described_class.new(container: project, cluster: cluster, current_user: project.owner, params: params) + described_class.new(container: project, cluster: cluster, current_user: project.first_owner, params: params) end shared_examples_for 'a cluster integration' do |application_type| diff --git a/spec/services/customer_relations/contacts/create_service_spec.rb b/spec/services/customer_relations/contacts/create_service_spec.rb index 71eb447055e..567e1c91e78 100644 --- a/spec/services/customer_relations/contacts/create_service_spec.rb +++ b/spec/services/customer_relations/contacts/create_service_spec.rb @@ -12,7 +12,7 @@ RSpec.describe CustomerRelations::Contacts::CreateService do subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } context 'when user does not have permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_reporter(user) @@ -25,7 +25,7 @@ RSpec.describe CustomerRelations::Contacts::CreateService do end context 'when user has permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_developer(user) diff --git a/spec/services/customer_relations/contacts/update_service_spec.rb b/spec/services/customer_relations/contacts/update_service_spec.rb index 7c5fbabb600..253bbc23226 100644 --- a/spec/services/customer_relations/contacts/update_service_spec.rb +++ b/spec/services/customer_relations/contacts/update_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe CustomerRelations::Contacts::UpdateService do describe '#execute' do context 'when the user has no permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:params) { { first_name: 'Gary' } } @@ -24,7 +24,7 @@ RSpec.describe CustomerRelations::Contacts::UpdateService do end context 'when user has permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_developer(user) diff --git a/spec/services/customer_relations/organizations/create_service_spec.rb b/spec/services/customer_relations/organizations/create_service_spec.rb index d8985d8d90b..18eefdd716e 100644 --- a/spec/services/customer_relations/organizations/create_service_spec.rb +++ b/spec/services/customer_relations/organizations/create_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe CustomerRelations::Organizations::CreateService do describe '#execute' do let_it_be(:user) { create(:user) } - let(:group) { create(:group) } + let(:group) { create(:group, :crm_enabled) } let(:params) { attributes_for(:organization, group: group) } subject(:response) { described_class.new(group: group, current_user: user, params: params).execute } diff --git a/spec/services/customer_relations/organizations/update_service_spec.rb b/spec/services/customer_relations/organizations/update_service_spec.rb index bc40cb3e8e7..8461c98ef0e 100644 --- a/spec/services/customer_relations/organizations/update_service_spec.rb +++ b/spec/services/customer_relations/organizations/update_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do describe '#execute' do context 'when the user has no permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let(:params) { { name: 'GitLab' } } @@ -24,7 +24,7 @@ RSpec.describe CustomerRelations::Organizations::UpdateService do end context 'when user has permission' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } before_all do group.add_developer(user) diff --git a/spec/services/dependency_proxy/download_blob_service_spec.rb b/spec/services/dependency_proxy/download_blob_service_spec.rb deleted file mode 100644 index 2f293b8a46b..00000000000 --- a/spec/services/dependency_proxy/download_blob_service_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe DependencyProxy::DownloadBlobService do - include DependencyProxyHelpers - - let(:image) { 'alpine' } - let(:token) { Digest::SHA256.hexdigest('123') } - let(:blob_sha) { Digest::SHA256.hexdigest('ruby:2.7.0') } - - subject(:download_blob) { described_class.new(image, blob_sha, token).execute } - - context 'remote request is successful' do - before do - stub_blob_download(image, blob_sha) - end - - it { expect(subject[:status]).to eq(:success) } - it { expect(subject[:file]).to be_a(Tempfile) } - it { expect(subject[:file].size).to eq(6) } - - it 'streams the download' do - expected_options = { headers: anything, stream_body: true } - - expect(Gitlab::HTTP).to receive(:perform_request).with(Net::HTTP::Get, anything, expected_options) - - download_blob - end - - it 'skips read_total_timeout', :aggregate_failures do - stub_const('GitLab::HTTP::DEFAULT_READ_TOTAL_TIMEOUT', 0) - - expect(Gitlab::Metrics::System).not_to receive(:monotonic_time) - expect(download_blob).to include(status: :success) - end - end - - context 'remote request is not found' do - before do - stub_blob_download(image, blob_sha, 404) - end - - it { expect(subject[:status]).to eq(:error) } - it { expect(subject[:http_status]).to eq(404) } - it { expect(subject[:message]).to eq('Non-success response code on downloading blob fragment') } - end - - context 'net timeout exception' do - before do - blob_url = DependencyProxy::Registry.blob_url(image, blob_sha) - - stub_full_request(blob_url).to_timeout - end - - it { expect(subject[:status]).to eq(:error) } - it { expect(subject[:http_status]).to eq(599) } - it { expect(subject[:message]).to eq('execution expired') } - end -end diff --git a/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb b/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb index 29bdf1f11c3..607d67d8efe 100644 --- a/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb +++ b/spec/services/dependency_proxy/find_cached_manifest_service_spec.rb @@ -91,9 +91,9 @@ RSpec.describe DependencyProxy::FindCachedManifestService do it_behaves_like 'returning no manifest' end - context 'when the cached manifest is expired' do + context 'when the cached manifest is pending destruction' do before do - dependency_proxy_manifest.update_column(:status, DependencyProxy::Manifest.statuses[:expired]) + dependency_proxy_manifest.update_column(:status, DependencyProxy::Manifest.statuses[:pending_destruction]) stub_manifest_head(image, tag, headers: headers) stub_manifest_download(image, tag, headers: headers) end diff --git a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb deleted file mode 100644 index 5f7afdf699a..00000000000 --- a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -RSpec.describe DependencyProxy::FindOrCreateBlobService do - include DependencyProxyHelpers - - let_it_be_with_reload(:blob) { create(:dependency_proxy_blob) } - - let(:group) { blob.group } - let(:image) { 'alpine' } - let(:tag) { '3.9' } - let(:token) { Digest::SHA256.hexdigest('123') } - let(:blob_sha) { '40bd001563085fc35165329ea1ff5c5ecbdbbeef' } - - subject { described_class.new(group, image, token, blob_sha).execute } - - before do - stub_registry_auth(image, token) - end - - shared_examples 'downloads the remote blob' do - it 'downloads blob from remote registry if there is no cached one' do - expect(subject[:status]).to eq(:success) - expect(subject[:blob]).to be_a(DependencyProxy::Blob) - expect(subject[:blob]).to be_persisted - expect(subject[:from_cache]).to eq false - end - end - - context 'no cache' do - before do - stub_blob_download(image, blob_sha) - end - - it_behaves_like 'downloads the remote blob' - end - - context 'cached blob' do - let(:blob_sha) { blob.file_name.sub('.gz', '') } - - it 'uses cached blob instead of downloading one' do - expect { subject }.to change { blob.reload.read_at } - - expect(subject[:status]).to eq(:success) - expect(subject[:blob]).to be_a(DependencyProxy::Blob) - expect(subject[:blob]).to eq(blob) - expect(subject[:from_cache]).to eq true - end - - context 'when the cached blob is expired' do - before do - blob.update_column(:status, DependencyProxy::Blob.statuses[:expired]) - stub_blob_download(image, blob_sha) - end - - it_behaves_like 'downloads the remote blob' - end - end - - context 'no such blob exists remotely' do - before do - stub_blob_download(image, blob_sha, 404) - end - - it 'returns error message and http status' do - expect(subject[:status]).to eq(:error) - expect(subject[:message]).to eq('Failed to download the blob') - expect(subject[:http_status]).to eq(404) - end - end -end diff --git a/spec/services/deployments/archive_in_project_service_spec.rb b/spec/services/deployments/archive_in_project_service_spec.rb index d4039ee7b4a..a316c210d64 100644 --- a/spec/services/deployments/archive_in_project_service_spec.rb +++ b/spec/services/deployments/archive_in_project_service_spec.rb @@ -50,17 +50,6 @@ RSpec.describe Deployments::ArchiveInProjectService do end end - context 'when deployments_archive feature flag is disabled' do - before do - stub_feature_flags(deployments_archive: false) - end - - it 'does not do anything' do - expect(subject[:status]).to eq(:error) - expect(subject[:message]).to eq('Feature flag is not enabled') - end - end - def deployment_refs_exist? deployment_refs.map { |path| project.repository.ref_exists?(path) } end diff --git a/spec/services/deployments/create_for_build_service_spec.rb b/spec/services/deployments/create_for_build_service_spec.rb new file mode 100644 index 00000000000..6fc7c9e56a6 --- /dev/null +++ b/spec/services/deployments/create_for_build_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deployments::CreateForBuildService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + let(:service) { described_class.new } + + describe '#execute' do + subject { service.execute(build) } + + context 'with a deployment job' do + let!(:build) { create(:ci_build, :start_review_app, project: project) } + let!(:environment) { create(:environment, project: project, name: build.expanded_environment_name) } + + it 'creates a deployment record' do + expect { subject }.to change { Deployment.count }.by(1) + + build.reset + expect(build.deployment.project).to eq(build.project) + expect(build.deployment.ref).to eq(build.ref) + expect(build.deployment.sha).to eq(build.sha) + expect(build.deployment.deployable).to eq(build) + expect(build.deployment.deployable_type).to eq('CommitStatus') + expect(build.deployment.environment).to eq(build.persisted_environment) + end + + context 'when creation failure occures' do + before do + allow(build).to receive(:create_deployment!) { raise ActiveRecord::RecordInvalid } + end + + it 'trackes the exception' do + expect { subject }.to raise_error(described_class::DeploymentCreationError) + + expect(Deployment.count).to eq(0) + end + end + + context 'when the corresponding environment does not exist' do + let!(:environment) { } + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + + expect(build.deployment).to be_nil + end + end + end + + context 'with a teardown job' do + let!(:build) { create(:ci_build, :stop_review_app, project: project) } + let!(:environment) { create(:environment, name: build.expanded_environment_name) } + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + + expect(build.deployment).to be_nil + end + end + + context 'with a normal job' do + let!(:build) { create(:ci_build, project: project) } + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + + expect(build.deployment).to be_nil + end + end + + context 'with a bridge' do + let!(:build) { create(:ci_bridge, project: project) } + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + end + end + end +end diff --git a/spec/services/discussions/update_diff_position_service_spec.rb b/spec/services/discussions/update_diff_position_service_spec.rb index 85020e95c83..e7a3505bbd4 100644 --- a/spec/services/discussions/update_diff_position_service_spec.rb +++ b/spec/services/discussions/update_diff_position_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Discussions::UpdateDiffPositionService do let(:project) { create(:project, :repository) } - let(:current_user) { project.owner } + let(:current_user) { project.first_owner } let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") } let(:modify_commit) { project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e") } let(:edit_commit) { project.commit("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") } diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb index 52d095148c8..2b16612dac3 100644 --- a/spec/services/error_tracking/collect_error_service_spec.rb +++ b/spec/services/error_tracking/collect_error_service_spec.rb @@ -4,8 +4,9 @@ require 'spec_helper' RSpec.describe ErrorTracking::CollectErrorService do let_it_be(:project) { create(:project) } - let_it_be(:parsed_event_file) { 'error_tracking/parsed_event.json' } - let_it_be(:parsed_event) { Gitlab::Json.parse(fixture_file(parsed_event_file)) } + + let(:parsed_event_file) { 'error_tracking/parsed_event.json' } + let(:parsed_event) { parse_valid_event(parsed_event_file) } subject { described_class.new(project, nil, event: parsed_event) } @@ -43,7 +44,7 @@ RSpec.describe ErrorTracking::CollectErrorService do end context 'python sdk event' do - let(:parsed_event) { Gitlab::Json.parse(fixture_file('error_tracking/python_event.json')) } + let(:parsed_event_file) { 'error_tracking/python_event.json' } it 'creates a valid event' do expect { subject.execute }.to change { ErrorTracking::ErrorEvent.count }.by(1) @@ -75,7 +76,7 @@ RSpec.describe ErrorTracking::CollectErrorService do end context 'go payload' do - let(:parsed_event) { Gitlab::Json.parse(fixture_file('error_tracking/go_parsed_event.json')) } + let(:parsed_event_file) { 'error_tracking/go_parsed_event.json' } it 'has correct values set' do subject.execute @@ -92,6 +93,38 @@ RSpec.describe ErrorTracking::CollectErrorService do expect(event.environment).to eq 'Accumulate' expect(event.payload).to eq parsed_event end + + context 'with two exceptions' do + let(:parsed_event_file) { 'error_tracking/go_two_exception_event.json' } + + it 'reports using second exception', :aggregate_failures do + subject.execute + + event = ErrorTracking::ErrorEvent.last + error = event.error + + expect(error.name).to eq '*url.Error' + expect(error.description).to eq(%(Get \"foobar\": unsupported protocol scheme \"\")) + expect(error.platform).to eq 'go' + expect(error.actor).to eq('main(main)') + + expect(event.description).to eq(%(Get \"foobar\": unsupported protocol scheme \"\")) + expect(event.payload).to eq parsed_event + end + end end end + + private + + def parse_valid_event(parsed_event_file) + parsed_event = Gitlab::Json.parse(fixture_file(parsed_event_file)) + + validator = ErrorTracking::Collector::PayloadValidator.new + # This a precondition for all specs to verify that + # submitted JSON payload is valid. + expect(validator).to be_valid(parsed_event) + + parsed_event + end end diff --git a/spec/services/events/destroy_service_spec.rb b/spec/services/events/destroy_service_spec.rb index 8dcbb83eb1d..8b07852c040 100644 --- a/spec/services/events/destroy_service_spec.rb +++ b/spec/services/events/destroy_service_spec.rb @@ -30,16 +30,28 @@ RSpec.describe Events::DestroyService do expect(unrelated_event.reload).to be_present end + context 'batch delete' do + before do + stub_const("#{described_class}::BATCH_SIZE", 2) + end + + it 'splits delete queries into batches' do + expect(project).to receive(:events).twice.and_call_original + + subject.execute + end + end + context 'when an error is raised while deleting the records' do before do - allow(project).to receive_message_chain(:events, :all, :delete_all).and_raise(ActiveRecord::ActiveRecordError) + allow(project).to receive_message_chain(:events, :limit, :delete_all).and_raise(ActiveRecord::ActiveRecordError, 'custom error') end it 'returns error' do response = subject.execute expect(response).to be_error - expect(response.message).to eq 'Failed to remove events.' + expect(response.message).to eq 'custom error' end it 'does not delete events' do diff --git a/spec/services/feature_flags/hook_service_spec.rb b/spec/services/feature_flags/hook_service_spec.rb index 02cdbbd86ac..19c935e43f3 100644 --- a/spec/services/feature_flags/hook_service_spec.rb +++ b/spec/services/feature_flags/hook_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe FeatureFlags::HookService do let_it_be(:namespace) { create(:namespace) } let_it_be(:project) { create(:project, :repository, namespace: namespace) } let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } - let_it_be(:user) { namespace.owner } + let_it_be(:user) { namespace.first_owner } let!(:hook) { create(:project_hook, project: project) } let(:hook_data) { double } diff --git a/spec/services/git/process_ref_changes_service_spec.rb b/spec/services/git/process_ref_changes_service_spec.rb index f52df9b0073..05c1f898cab 100644 --- a/spec/services/git/process_ref_changes_service_spec.rb +++ b/spec/services/git/process_ref_changes_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Git::ProcessRefChangesService do let(:project) { create(:project, :repository) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:params) { { changes: git_changes } } subject { described_class.new(project, user, params) } @@ -34,7 +34,7 @@ RSpec.describe Git::ProcessRefChangesService do it "calls #{push_service_class}" do expect(push_service_class) .to receive(:new) - .with(project, project.owner, hash_including(execute_project_hooks: true, create_push_event: true)) + .with(project, project.first_owner, hash_including(execute_project_hooks: true, create_push_event: true)) .exactly(changes.count).times .and_return(service) @@ -58,7 +58,7 @@ RSpec.describe Git::ProcessRefChangesService do it "calls #{push_service_class} with execute_project_hooks set to false" do expect(push_service_class) .to receive(:new) - .with(project, project.owner, hash_including(execute_project_hooks: false)) + .with(project, project.first_owner, hash_including(execute_project_hooks: false)) .exactly(changes.count).times .and_return(service) @@ -86,7 +86,7 @@ RSpec.describe Git::ProcessRefChangesService do it "calls #{push_service_class} with create_push_event set to false" do expect(push_service_class) .to receive(:new) - .with(project, project.owner, hash_including(create_push_event: false)) + .with(project, project.first_owner, hash_including(create_push_event: false)) .exactly(changes.count).times .and_return(service) @@ -170,7 +170,7 @@ RSpec.describe Git::ProcessRefChangesService do allow(push_service_class) .to receive(:new) - .with(project, project.owner, hash_including(execute_project_hooks: true, create_push_event: true)) + .with(project, project.first_owner, hash_including(execute_project_hooks: true, create_push_event: true)) .exactly(changes.count).times .and_return(service) end diff --git a/spec/services/google_cloud/create_service_accounts_service_spec.rb b/spec/services/google_cloud/create_service_accounts_service_spec.rb new file mode 100644 index 00000000000..190e1a8098c --- /dev/null +++ b/spec/services/google_cloud/create_service_accounts_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Mock Types +MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret) +MockServiceAccount = Struct.new(:project_id, :unique_id) + +RSpec.describe GoogleCloud::CreateServiceAccountsService do + describe '#execute' do + before do + allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for) + .with('google_oauth2') + .and_return(MockGoogleOAuth2Credentials.new('mock-app-id', 'mock-app-secret')) + + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + allow(client).to receive(:create_service_account) + .and_return(MockServiceAccount.new('mock-project-id', 'mock-unique-id')) + allow(client).to receive(:create_service_account_key) + .and_return('mock-key') + end + end + + it 'creates unprotected vars', :aggregate_failures do + project = create(:project) + + service = described_class.new( + project, + nil, + google_oauth2_token: 'mock-token', + gcp_project_id: 'mock-gcp-project-id', + environment_name: '*' + ) + + response = service.execute + + expect(response.status).to eq(:success) + expect(response.message).to eq('Service account generated successfully') + expect(project.variables.count).to eq(3) + expect(project.variables.first.protected).to eq(false) + expect(project.variables.second.protected).to eq(false) + expect(project.variables.third.protected).to eq(false) + end + end +end diff --git a/spec/services/google_cloud/service_accounts_service_spec.rb b/spec/services/google_cloud/service_accounts_service_spec.rb index 505c623c02a..17c1f61a96e 100644 --- a/spec/services/google_cloud/service_accounts_service_spec.rb +++ b/spec/services/google_cloud/service_accounts_service_spec.rb @@ -60,8 +60,8 @@ RSpec.describe GoogleCloud::ServiceAccountsService do let_it_be(:project) { create(:project) } it 'saves GCP creds as project CI vars' do - service.add_for_project('env_1', 'gcp_prj_id_1', 'srv_acc_1', 'srv_acc_key_1') - service.add_for_project('env_2', 'gcp_prj_id_2', 'srv_acc_2', 'srv_acc_key_2') + service.add_for_project('env_1', 'gcp_prj_id_1', 'srv_acc_1', 'srv_acc_key_1', true) + service.add_for_project('env_2', 'gcp_prj_id_2', 'srv_acc_2', 'srv_acc_key_2', false) list = service.find_for_project @@ -81,7 +81,7 @@ RSpec.describe GoogleCloud::ServiceAccountsService do end it 'replaces previously stored CI vars with new CI vars' do - service.add_for_project('env_1', 'new_project', 'srv_acc_1', 'srv_acc_key_1') + service.add_for_project('env_1', 'new_project', 'srv_acc_1', 'srv_acc_key_1', false) list = service.find_for_project @@ -101,9 +101,16 @@ RSpec.describe GoogleCloud::ServiceAccountsService do end end - it 'underlying project CI vars must be protected' do - expect(project.variables.first.protected).to eq(true) - expect(project.variables.second.protected).to eq(true) + it 'underlying project CI vars must be protected as per value' do + service.add_for_project('env_1', 'gcp_prj_id_1', 'srv_acc_1', 'srv_acc_key_1', true) + service.add_for_project('env_2', 'gcp_prj_id_2', 'srv_acc_2', 'srv_acc_key_2', false) + + expect(project.variables[0].protected).to eq(true) + expect(project.variables[1].protected).to eq(true) + expect(project.variables[2].protected).to eq(true) + expect(project.variables[3].protected).to eq(false) + expect(project.variables[4].protected).to eq(false) + expect(project.variables[5].protected).to eq(false) end end end diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb index e1bd3732820..46c5e2a9818 100644 --- a/spec/services/groups/update_service_spec.rb +++ b/spec/services/groups/update_service_spec.rb @@ -163,6 +163,70 @@ RSpec.describe Groups::UpdateService do expect(updated_group.parent_id).to be_nil end end + + context 'crm_enabled param' do + context 'when no existing crm_settings' do + it 'when param not present, leave crm disabled' do + params = {} + + described_class.new(public_group, user, params).execute + updated_group = public_group.reload + + expect(updated_group.crm_enabled?).to be_falsey + end + + it 'when param set true, enables crm' do + params = { crm_enabled: true } + + described_class.new(public_group, user, params).execute + updated_group = public_group.reload + + expect(updated_group.crm_enabled?).to be_truthy + end + end + + context 'with existing crm_settings' do + it 'when param set true, enables crm' do + params = { crm_enabled: true } + create(:crm_settings, group: public_group) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_truthy + end + + it 'when param set false, disables crm' do + params = { crm_enabled: false } + create(:crm_settings, group: public_group, enabled: true) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_falsy + end + + it 'when param not present, crm remains disabled' do + params = {} + create(:crm_settings, group: public_group) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_falsy + end + + it 'when param not present, crm remains enabled' do + params = {} + create(:crm_settings, group: public_group, enabled: true) + + described_class.new(public_group, user, params).execute + + updated_group = public_group.reload + expect(updated_group.crm_enabled?).to be_truthy + end + end + end end context "unauthorized visibility_level validation" do diff --git a/spec/services/import/gitlab_projects/create_project_from_remote_file_service_spec.rb b/spec/services/import/gitlab_projects/create_project_from_remote_file_service_spec.rb index 3c461c91ff0..92c46cf7052 100644 --- a/spec/services/import/gitlab_projects/create_project_from_remote_file_service_spec.rb +++ b/spec/services/import/gitlab_projects/create_project_from_remote_file_service_spec.rb @@ -18,24 +18,29 @@ RSpec.describe ::Import::GitlabProjects::CreateProjectFromRemoteFileService do subject { described_class.new(user, params) } - it 'creates a project and returns a successful response' do - stub_headers_for(remote_url, { - 'content-type' => 'application/gzip', - 'content-length' => '10' - }) - - response = nil - expect { response = subject.execute } - .to change(Project, :count).by(1) + shared_examples 'successfully import' do |content_type| + it 'creates a project and returns a successful response' do + stub_headers_for(remote_url, { + 'content-type' => content_type, + 'content-length' => '10' + }) - expect(response).to be_success - expect(response.http_status).to eq(:ok) - expect(response.payload).to be_instance_of(Project) - expect(response.payload.name).to eq('name') - expect(response.payload.path).to eq('path') - expect(response.payload.namespace).to eq(user.namespace) + response = nil + expect { response = subject.execute } + .to change(Project, :count).by(1) + + expect(response).to be_success + expect(response.http_status).to eq(:ok) + expect(response.payload).to be_instance_of(Project) + expect(response.payload.name).to eq('name') + expect(response.payload.path).to eq('path') + expect(response.payload.namespace).to eq(user.namespace) + end end + it_behaves_like 'successfully import', 'application/gzip' + it_behaves_like 'successfully import', 'application/x-tar' + context 'when the file url is invalid' do it 'returns an erred response with the reason of the failure' do stub_application_setting(allow_local_requests_from_web_hooks_and_services: false) @@ -79,7 +84,7 @@ RSpec.describe ::Import::GitlabProjects::CreateProjectFromRemoteFileService do expect(response).not_to be_success expect(response.http_status).to eq(:bad_request) expect(response.message) - .to eq("Remote file content type 'application/js' not allowed. (Allowed content types: application/gzip)") + .to eq("Remote file content type 'application/js' not allowed. (Allowed content types: application/gzip, application/x-tar)") end end @@ -130,6 +135,20 @@ RSpec.describe ::Import::GitlabProjects::CreateProjectFromRemoteFileService do end end + it 'does not validate content-type or content-length when the file is stored in AWS-S3' do + stub_headers_for(remote_url, { + 'Server' => 'AmazonS3', + 'x-amz-request-id' => 'Something' + }) + + response = nil + expect { response = subject.execute } + .to change(Project, :count) + + expect(response).to be_success + expect(response.http_status).to eq(:ok) + end + context 'when required parameters are not provided' do let(:params) { {} } diff --git a/spec/services/import/validate_remote_git_endpoint_service_spec.rb b/spec/services/import/validate_remote_git_endpoint_service_spec.rb index fbd8a3cb323..9dc862b6ca3 100644 --- a/spec/services/import/validate_remote_git_endpoint_service_spec.rb +++ b/spec/services/import/validate_remote_git_endpoint_service_spec.rb @@ -24,6 +24,17 @@ RSpec.describe Import::ValidateRemoteGitEndpointService do expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: nil, stream_body: true, follow_redirects: false) end + context 'when uri is using git:// protocol' do + subject { described_class.new(url: 'git://demo.host/repo')} + + it 'returns success' do + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + end + context 'when receiving HTTP response' do subject { described_class.new(url: base_url) } diff --git a/spec/services/incident_management/incidents/create_service_spec.rb b/spec/services/incident_management/incidents/create_service_spec.rb index 0f32a4b5425..47ce9d01f66 100644 --- a/spec/services/incident_management/incidents/create_service_spec.rb +++ b/spec/services/incident_management/incidents/create_service_spec.rb @@ -39,7 +39,7 @@ RSpec.describe IncidentManagement::Incidents::CreateService do let(:issue) { new_issue } - include_examples 'has incident label' + include_examples 'does not have incident label' end context 'with default severity' do @@ -71,8 +71,8 @@ RSpec.describe IncidentManagement::Incidents::CreateService do end context 'when incident label does not exists' do - it 'creates incident label' do - expect { create_incident }.to change { project.labels.where(title: label_title).count }.by(1) + it 'does not create incident label' do + expect { create_incident }.to not_change { project.labels.where(title: label_title).count } end end diff --git a/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb new file mode 100644 index 00000000000..e9db6ba8d28 --- /dev/null +++ b/spec/services/incident_management/issuable_escalation_statuses/after_update_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IncidentManagement::IssuableEscalationStatuses::AfterUpdateService do + let_it_be(:current_user) { create(:user) } + let_it_be(:escalation_status, reload: true) { create(:incident_management_issuable_escalation_status, :triggered) } + let_it_be(:issue, reload: true) { escalation_status.issue } + let_it_be(:project) { issue.project } + let_it_be(:alert) { create(:alert_management_alert, issue: issue, project: project) } + + let(:status_event) { :acknowledge } + let(:update_params) { { incident_management_issuable_escalation_status_attributes: { status_event: status_event } } } + let(:service) { IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new(issue, current_user) } + + subject(:result) do + issue.update!(update_params) + service.execute + end + + before do + issue.project.add_developer(current_user) + end + + shared_examples 'does not attempt to update the alert' do + specify do + expect(::AlertManagement::Alerts::UpdateService).not_to receive(:new) + + expect(result).to be_success + end + end + + context 'with status attributes' do + it 'updates the alert with the new alert status' do + expect(::AlertManagement::Alerts::UpdateService).to receive(:new).once.and_call_original + expect(described_class).to receive(:new).once.and_call_original + + expect { result }.to change { escalation_status.reload.acknowledged? }.to(true) + .and change { alert.reload.acknowledged? }.to(true) + end + + context 'when incident is not associated with an alert' do + before do + alert.destroy! + end + + it_behaves_like 'does not attempt to update the alert' + end + + context 'when new status matches the current status' do + let(:status_event) { :trigger } + + it_behaves_like 'does not attempt to update the alert' + end + end +end diff --git a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb new file mode 100644 index 00000000000..bfed5319028 --- /dev/null +++ b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService do + let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, :triggered) } + let_it_be(:user_with_permissions) { create(:user) } + + let(:current_user) { user_with_permissions } + let(:issue) { escalation_status.issue } + let(:status) { :acknowledged } + let(:params) { { status: status } } + let(:service) { IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService.new(issue, current_user, params) } + + subject(:result) { service.execute } + + before do + issue.project.add_developer(user_with_permissions) + end + + shared_examples 'successful response' do |payload| + it 'returns valid parameters which can be used to update the issue' do + expect(result).to be_success + expect(result.payload).to eq(escalation_status: payload) + end + end + + shared_examples 'error response' do |message| + specify do + expect(result).to be_error + expect(result.message).to eq(message) + end + end + + shared_examples 'availability error response' do + include_examples 'error response', 'Escalation status updates are not available for this issue, user, or project.' + end + + shared_examples 'invalid params error response' do + include_examples 'error response', 'Invalid value was provided for a parameter.' + end + + it_behaves_like 'successful response', { status_event: :acknowledge } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it_behaves_like 'availability error response' + end + + context 'when user is anonymous' do + let(:current_user) { nil } + + it_behaves_like 'availability error response' + end + + context 'when user does not have permissions' do + let(:current_user) { create(:user) } + + it_behaves_like 'availability error response' + end + + context 'when called with an unsupported issue type' do + let(:issue) { create(:issue) } + + it_behaves_like 'availability error response' + end + + context 'when an IssuableEscalationStatus record for the issue does not exist' do + let(:issue) { create(:incident) } + + it_behaves_like 'availability error response' + end + + context 'when called without params' do + let(:params) { nil } + + it_behaves_like 'successful response', {} + end + + context 'when called with unsupported params' do + let(:params) { { escalations_started_at: Time.current } } + + it_behaves_like 'successful response', {} + end + + context 'with status param' do + context 'when status matches the current status' do + let(:params) { { status: :triggered } } + + it_behaves_like 'successful response', {} + end + + context 'when status is unsupported' do + let(:params) { { status: :mitigated } } + + it_behaves_like 'invalid params error response' + end + + context 'when status is a String' do + let(:params) { { status: 'acknowledged' } } + + it_behaves_like 'successful response', { status_event: :acknowledge } + end + end +end diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb index 32f9f632d7a..74833686283 100644 --- a/spec/services/integrations/test/project_service_spec.rb +++ b/spec/services/integrations/test/project_service_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Integrations::Test::ProjectService do let_it_be(:project) { create(:project) } let(:integration) { create(:integrations_slack, project: project) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:event) { nil } let(:sample_data) { { data: 'sample' } } let(:success_result) { { success: true, result: {} } } diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index cf75efb5c57..304e4bb3ebb 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -172,9 +172,9 @@ RSpec.describe Issues::BuildService do end describe 'setting issue type' do - context 'with a corresponding WorkItem::Type' do - let_it_be(:type_issue_id) { WorkItem::Type.default_issue_type.id } - let_it_be(:type_incident_id) { WorkItem::Type.default_by_type(:incident).id } + context 'with a corresponding WorkItems::Type' do + let_it_be(:type_issue_id) { WorkItems::Type.default_issue_type.id } + let_it_be(:type_incident_id) { WorkItems::Type.default_by_type(:incident).id } where(:issue_type, :current_user, :work_item_type_id, :resulting_issue_type) do nil | ref(:guest) | ref(:type_issue_id) | 'issue' diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 8496bd31e00..b2dcfb5c6d3 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Issues::CreateService do include AfterNextHelpers - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be_with_reload(:project) { create(:project, group: group) } let_it_be(:user) { create(:user) } @@ -61,6 +61,7 @@ RSpec.describe Issues::CreateService do expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute) expect(issue).to be_persisted + expect(issue).to be_a(::Issue) expect(issue.title).to eq('Awesome issue') expect(issue.assignees).to eq([assignee]) expect(issue.labels).to match_array(labels) @@ -69,6 +70,18 @@ RSpec.describe Issues::CreateService do expect(issue.work_item_type.base_type).to eq('issue') end + context 'when a build_service is provided' do + let(:issue) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params, build_service: build_service).execute } + + let(:issue_from_builder) { WorkItem.new(project: project, title: 'Issue from builder') } + let(:build_service) { double(:build_service, execute: issue_from_builder) } + + it 'uses the provided service to build the issue' do + expect(issue).to be_persisted + expect(issue).to be_a(WorkItem) + end + end + context 'when skip_system_notes is true' do let(:issue) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params).execute(skip_system_notes: true) } @@ -101,11 +114,11 @@ RSpec.describe Issues::CreateService do end it_behaves_like 'incident issue' - it_behaves_like 'has incident label' + it_behaves_like 'does not have incident label' - it 'does create an incident label' do + it 'does not create an incident label' do expect { subject } - .to change { Label.where(incident_label_attributes).count }.by(1) + .to not_change { Label.where(incident_label_attributes).count } end it 'calls IncidentManagement::Incidents::CreateEscalationStatusService' do diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 36af38aef18..ef501f47f0d 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -259,6 +259,16 @@ RSpec.describe Issues::MoveService do it_behaves_like 'copy or reset relative position' end + + context 'issue with escalation status' do + it 'keeps the escalation status' do + escalation_status = create(:incident_management_issuable_escalation_status, issue: old_issue) + + move_service.execute(old_issue, new_project) + + expect(escalation_status.reload.issue).to eq(old_issue) + end + end end describe 'move permissions' do diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb index 628f70efad6..2418f317551 100644 --- a/spec/services/issues/set_crm_contacts_service_spec.rb +++ b/spec/services/issues/set_crm_contacts_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Issues::SetCrmContactsService do let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be(:project) { create(:project, group: group) } let_it_be(:contacts) { create_list(:contact, 4, group: group) } diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 4739b7e0f28..11ed47b84d9 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Issues::UpdateService, :mailer do let_it_be(:user2) { create(:user) } let_it_be(:user3) { create(:user) } let_it_be(:guest) { create(:user) } - let_it_be(:group) { create(:group, :public) } + let_it_be(:group) { create(:group, :public, :crm_enabled) } let_it_be(:project, reload: true) { create(:project, :repository, group: group) } let_it_be(:label) { create(:label, project: project) } let_it_be(:label2) { create(:label, project: project) } @@ -22,10 +22,10 @@ RSpec.describe Issues::UpdateService, :mailer do end before_all do - project.add_maintainer(user) - project.add_developer(user2) - project.add_developer(user3) - project.add_guest(guest) + group.add_maintainer(user) + group.add_developer(user2) + group.add_developer(user3) + group.add_guest(guest) end describe 'execute' do @@ -191,11 +191,6 @@ RSpec.describe Issues::UpdateService, :mailer do end end - it 'adds a `incident` label if one does not exist' do - expect { update_issue(issue_type: 'incident') }.to change(issue.labels, :count).by(1) - expect(issue.labels.pluck(:title)).to eq(['incident']) - end - it 'creates system note about issue type' do update_issue(issue_type: 'incident') @@ -204,6 +199,13 @@ RSpec.describe Issues::UpdateService, :mailer do expect(note).not_to eq(nil) end + it 'creates an escalation status' do + expect { update_issue(issue_type: 'incident') } + .to change { issue.reload.incident_management_issuable_escalation_status } + .from(nil) + .to(a_kind_of(IncidentManagement::IssuableEscalationStatus)) + end + context 'for an issue with multiple labels' do let(:issue) { create(:incident, project: project, labels: [label_1]) } @@ -215,18 +217,6 @@ RSpec.describe Issues::UpdateService, :mailer do expect(issue.labels).to eq([label_1]) end end - - context 'filtering the incident label' do - let(:params) { { add_label_ids: [] } } - - before do - update_issue(issue_type: 'incident') - end - - it 'creates and add a incident label id to add_label_ids' do - expect(issue.label_ids).to contain_exactly(label_1.id) - end - end end context 'from incident to issue' do @@ -241,10 +231,8 @@ RSpec.describe Issues::UpdateService, :mailer do context 'for an incident with multiple labels' do let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) } - it 'removes an `incident` label if one exists on the incident' do - expect { update_issue(issue_type: 'issue') }.to change(issue, :label_ids) - .from(containing_exactly(label_1.id, label_2.id)) - .to([label_2.id]) + it 'does not remove an `incident` label if one exists on the incident' do + expect { update_issue(issue_type: 'issue') }.to not_change(issue, :label_ids) end end @@ -252,10 +240,8 @@ RSpec.describe Issues::UpdateService, :mailer do let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) } let(:params) { { label_ids: [label_1.id, label_2.id], remove_label_ids: [] } } - it 'adds an incident label id to remove_label_ids for it to be removed' do - expect { update_issue(issue_type: 'issue') }.to change(issue, :label_ids) - .from(containing_exactly(label_1.id, label_2.id)) - .to([label_2.id]) + it 'does not add an incident label id to remove_label_ids for it to be removed' do + expect { update_issue(issue_type: 'issue') }.to not_change(issue, :label_ids) end end end @@ -1157,6 +1143,83 @@ RSpec.describe Issues::UpdateService, :mailer do end end + context 'updating escalation status' do + let(:opts) { { escalation_status: { status: 'acknowledged' } } } + let(:escalation_update_class) { ::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService } + + shared_examples 'updates the escalation status record' do |expected_status| + let(:service_double) { instance_double(escalation_update_class) } + + it 'has correct value' do + expect(escalation_update_class).to receive(:new).with(issue, user).and_return(service_double) + expect(service_double).to receive(:execute) + + update_issue(opts) + + expect(issue.escalation_status.status_name).to eq(expected_status) + end + end + + shared_examples 'does not change the status record' do + it 'retains the original value' do + expected_status = issue.escalation_status&.status_name + + update_issue(opts) + + expect(issue.escalation_status&.status_name).to eq(expected_status) + end + + it 'does not trigger side-effects' do + expect(escalation_update_class).not_to receive(:new) + + update_issue(opts) + end + end + + context 'when issue is an incident' do + let(:issue) { create(:incident, project: project) } + + context 'with an escalation status record' do + before do + create(:incident_management_issuable_escalation_status, issue: issue) + end + + it_behaves_like 'updates the escalation status record', :acknowledged + + context 'with associated alert' do + let!(:alert) { create(:alert_management_alert, issue: issue, project: project) } + + it 'syncs the update back to the alert' do + update_issue(opts) + + expect(issue.escalation_status.status_name).to eq(:acknowledged) + expect(alert.reload.status_name).to eq(:acknowledged) + end + end + + context 'with unsupported status value' do + let(:opts) { { escalation_status: { status: 'unsupported-status' } } } + + it_behaves_like 'does not change the status record' + end + + context 'with status value defined but unchanged' do + let(:opts) { { escalation_status: { status: issue.escalation_status.status_name } } } + + it_behaves_like 'does not change the status record' + end + end + + context 'without an escalation status record' do + it_behaves_like 'does not change the status record' + end + end + + context 'when issue type is not incident' do + it_behaves_like 'does not change the status record' + end + end + context 'duplicate issue' do let(:canonical_issue) { create(:issue, project: project) } diff --git a/spec/services/labels/transfer_service_spec.rb b/spec/services/labels/transfer_service_spec.rb index 05190accb33..e67ab6025a5 100644 --- a/spec/services/labels/transfer_service_spec.rb +++ b/spec/services/labels/transfer_service_spec.rb @@ -109,15 +109,5 @@ RSpec.describe Labels::TransferService do end end - context 'with use_optimized_group_labels_query FF on' do - it_behaves_like 'transfer labels' - end - - context 'with use_optimized_group_labels_query FF off' do - before do - stub_feature_flags(use_optimized_group_labels_query: false) - end - - it_behaves_like 'transfer labels' - end + it_behaves_like 'transfer labels' end diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb index b9f382d3cd8..1a1283b1078 100644 --- a/spec/services/members/destroy_service_spec.rb +++ b/spec/services/members/destroy_service_spec.rb @@ -424,7 +424,7 @@ RSpec.describe Members::DestroyService do end context 'deletion of invitations created by deleted project member' do - let(:user) { project.owner } + let(:user) { project.first_owner } let(:member_user) { create(:user) } let(:project) { create(:project) } diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb index 7b9ae19f038..8ceb9979f33 100644 --- a/spec/services/members/invite_service_spec.rb +++ b/spec/services/members/invite_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_shared_state, :sidekiq_inline do let_it_be(:project, reload: true) { create(:project) } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let_it_be(:project_user) { create(:user) } let_it_be(:namespace) { project.namespace } diff --git a/spec/services/merge_requests/base_service_spec.rb b/spec/services/merge_requests/base_service_spec.rb index 7911392ef19..6eeba3029ae 100644 --- a/spec/services/merge_requests/base_service_spec.rb +++ b/spec/services/merge_requests/base_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe MergeRequests::BaseService do } end - subject { MergeRequests::CreateService.new(project: project, current_user: project.owner, params: params) } + subject { MergeRequests::CreateService.new(project: project, current_user: project.first_owner, params: params) } describe '#execute_hooks' do shared_examples 'enqueues Jira sync worker' do diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb index 4b21812503e..e5bea0e7b14 100644 --- a/spec/services/merge_requests/squash_service_spec.rb +++ b/spec/services/merge_requests/squash_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe MergeRequests::SquashService do include GitHelpers let(:service) { described_class.new(project: project, current_user: user, params: { merge_request: merge_request }) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:project) { create(:project, :repository) } let(:repository) { project.repository.raw } let(:log_error) { "Failed to squash merge request #{merge_request.to_reference(full: true)}:" } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 6ec2b158d30..2925dad7f6b 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -1132,7 +1132,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do context 'updating `force_remove_source_branch`' do let(:target_project) { create(:project, :repository, :public) } let(:source_project) { fork_project(target_project, nil, repository: true) } - let(:user) { target_project.owner } + let(:user) { target_project.first_owner } let(:merge_request) do create(:merge_request, source_project: source_project, diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 793e9ed9848..1fb50b07b3b 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -402,7 +402,7 @@ RSpec.describe Notes::CreateService do let_it_be(:design) { create(:design, :with_file) } let_it_be(:project) { design.project } - let_it_be(:user) { project.owner } + let_it_be(:user) { project.first_owner } let_it_be(:params) do { type: 'DiffNote', diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 24775ce06a4..9cbc16f0c95 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -2885,7 +2885,7 @@ RSpec.describe NotificationService, :mailer do let(:member) { create(:user) } before do - project.add_developer(member, current_user: project.owner) + project.add_developer(member, current_user: project.first_owner) end it do @@ -3287,7 +3287,7 @@ RSpec.describe NotificationService, :mailer do let_it_be(:domain, reload: true) { create(:pages_domain, project: project) } let_it_be(:u_blocked) { create(:user, :blocked) } let_it_be(:u_silence) { create_user_with_notification(:disabled, 'silent', project) } - let_it_be(:u_owner) { project.owner } + let_it_be(:u_owner) { project.first_owner } let_it_be(:u_maintainer1) { create(:user) } let_it_be(:u_maintainer2) { create(:user) } let_it_be(:u_developer) { create(:user) } @@ -3395,7 +3395,7 @@ RSpec.describe NotificationService, :mailer do let(:remote_mirror) { create(:remote_mirror, project: project) } let(:u_blocked) { create(:user, :blocked) } let(:u_silence) { create_user_with_notification(:disabled, 'silent-maintainer', project) } - let(:u_owner) { project.owner } + let(:u_owner) { project.first_owner } let(:u_maintainer1) { create(:user) } let(:u_maintainer2) { create(:user) } let(:u_developer) { create(:user) } @@ -3489,7 +3489,7 @@ RSpec.describe NotificationService, :mailer do it 'sends the email to owners and masters' do expect(Notify).to receive(:prometheus_alert_fired_email).with(project, master, alert).and_call_original - expect(Notify).to receive(:prometheus_alert_fired_email).with(project, project.owner, alert).and_call_original + expect(Notify).to receive(:prometheus_alert_fired_email).with(project, project.first_owner, alert).and_call_original expect(Notify).not_to receive(:prometheus_alert_fired_email).with(project, developer, alert) subject.prometheus_alerts_fired(project, [alert]) diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb index 122f1e88ad0..58fa68b11fe 100644 --- a/spec/services/packages/create_event_service_spec.rb +++ b/spec/services/packages/create_event_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' RSpec.describe Packages::CreateEventService do - let(:scope) { 'container' } + let(:scope) { 'generic' } let(:event_name) { 'push_package' } let(:params) do @@ -75,24 +75,24 @@ RSpec.describe Packages::CreateEventService do context 'with a user' do let(:user) { create(:user) } - it_behaves_like 'db package event creation', 'user', 'container' - it_behaves_like 'redis package unique event creation', 'user', 'container' - it_behaves_like 'redis package count event creation', 'user', 'container' + it_behaves_like 'db package event creation', 'user', 'generic' + it_behaves_like 'redis package unique event creation', 'user', 'generic' + it_behaves_like 'redis package count event creation', 'user', 'generic' end context 'with a deploy token' do let(:user) { create(:deploy_token) } - it_behaves_like 'db package event creation', 'deploy_token', 'container' - it_behaves_like 'redis package unique event creation', 'deploy_token', 'container' - it_behaves_like 'redis package count event creation', 'deploy_token', 'container' + it_behaves_like 'db package event creation', 'deploy_token', 'generic' + it_behaves_like 'redis package unique event creation', 'deploy_token', 'generic' + it_behaves_like 'redis package count event creation', 'deploy_token', 'generic' end context 'with no user' do let(:user) { nil } - it_behaves_like 'db package event creation', 'guest', 'container' - it_behaves_like 'redis package count event creation', 'guest', 'container' + it_behaves_like 'db package event creation', 'guest', 'generic' + it_behaves_like 'redis package count event creation', 'guest', 'generic' end context 'with a package as scope' do diff --git a/spec/services/packages/maven/metadata/sync_service_spec.rb b/spec/services/packages/maven/metadata/sync_service_spec.rb index 30ddb48207a..a736ed281f0 100644 --- a/spec/services/packages/maven/metadata/sync_service_spec.rb +++ b/spec/services/packages/maven/metadata/sync_service_spec.rb @@ -265,4 +265,22 @@ RSpec.describe ::Packages::Maven::Metadata::SyncService do end end end + + # TODO When cleaning up packages_installable_package_files, consider adding a + # dummy package file pending for destruction on L10/11 and remove this context + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: versionless_package_for_versions, file_name: Packages::Maven::Metadata.filename) } + + subject { service.send(:metadata_package_file_for, versionless_package_for_versions) } + + it { is_expected.not_to eq(package_file_pending_destruction) } + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it { is_expected.to eq(package_file_pending_destruction) } + end + end end diff --git a/spec/services/packages/terraform_module/create_package_service_spec.rb b/spec/services/packages/terraform_module/create_package_service_spec.rb index f911bb5b82c..e172aa726fd 100644 --- a/spec/services/packages/terraform_module/create_package_service_spec.rb +++ b/spec/services/packages/terraform_module/create_package_service_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Packages::TerraformModule::CreatePackageService do let!(:existing_package) { create(:terraform_module_package, project: project2, name: 'foo/bar', version: '1.0.0') } it { expect(subject[:http_status]).to eq 403 } - it { expect(subject[:message]).to be 'Package already exists.' } + it { expect(subject[:message]).to be 'Access Denied' } end context 'version already exists' do diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 2aa9be5066f..d5fbf96ce74 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -119,7 +119,7 @@ RSpec.describe Projects::CreateService, '#execute' do project = create_project(user, opts) expect(project).to be_valid - expect(project.owner).to eq(user) + expect(project.first_owner).to eq(user) expect(project.team.maintainers).to include(user) expect(project.namespace).to eq(user.namespace) expect(project.project_namespace).to be_in_sync_with_project(project) @@ -154,6 +154,7 @@ RSpec.describe Projects::CreateService, '#execute' do expect(project).to be_persisted expect(project.owner).to eq(user) + expect(project.first_owner).to eq(user) expect(project.team.maintainers).to contain_exactly(user) expect(project.namespace).to eq(user.namespace) expect(project.project_namespace).to be_in_sync_with_project(project) diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index b22f276ee1f..9475f562d71 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -64,7 +64,7 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do create(:ci_pipeline_artifact, pipeline: pipeline) create_list(:ci_build_trace_chunk, 3, build: builds[0]) - expect { destroy_project(project, project.owner, {}) }.not_to exceed_query_limit(recorder) + expect { destroy_project(project, project.first_owner, {}) }.not_to exceed_query_limit(recorder) end it_behaves_like 'deleting the project' @@ -78,6 +78,11 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do end.not_to raise_error end + it 'reports the error' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).and_call_original + destroy_project(project, user, {}) + end + it 'unmarks the project as "pending deletion"' do destroy_project(project, user, {}) diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 3f58fa46806..ce30a20edf4 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -61,7 +61,7 @@ RSpec.describe Projects::ForkService do it { expect(to_project).to be_persisted } it { expect(to_project.errors).to be_empty } - it { expect(to_project.owner).to eq(@to_user) } + it { expect(to_project.first_owner).to eq(@to_user) } it { expect(to_project.namespace).to eq(@to_user.namespace) } it { expect(to_project.star_count).to be_zero } it { expect(to_project.description).to eq(@from_project.description) } @@ -274,7 +274,7 @@ RSpec.describe Projects::ForkService do expect(to_project).to be_persisted expect(to_project.errors).to be_empty - expect(to_project.owner).to eq(@group) + expect(to_project.first_owner).to eq(@group_owner) expect(to_project.namespace).to eq(@group) expect(to_project.name).to eq(@project.name) expect(to_project.path).to eq(@project.path) diff --git a/spec/services/projects/prometheus/alerts/notify_service_spec.rb b/spec/services/projects/prometheus/alerts/notify_service_spec.rb index 3bd96ad19bc..0d0bb317df2 100644 --- a/spec/services/projects/prometheus/alerts/notify_service_spec.rb +++ b/spec/services/projects/prometheus/alerts/notify_service_spec.rb @@ -224,6 +224,78 @@ RSpec.describe Projects::Prometheus::Alerts::NotifyService do end end end + + context 'when payload exceeds max amount of processable alerts' do + # We are defining 2 alerts in payload_raw above + let(:max_alerts) { 1 } + + before do + stub_const("#{described_class}::PROCESS_MAX_ALERTS", max_alerts) + + create(:prometheus_integration, project: project) + create(:project_alerting_setting, project: project, token: token) + + allow(Gitlab::AppLogger).to receive(:warn) + end + + shared_examples 'process truncated alerts' do + it 'returns 200 but skips processing and logs a warning', :aggregate_failures do + expect(subject).to be_success + expect(subject.payload[:alerts].size).to eq(max_alerts) + expect(Gitlab::AppLogger) + .to have_received(:warn) + .with( + message: 'Prometheus payload exceeded maximum amount of alerts. Truncating alerts.', + project_id: project.id, + alerts: { + total: 2, + max: max_alerts + }) + end + end + + shared_examples 'process all alerts' do + it 'returns 200 and process alerts without warnings', :aggregate_failures do + expect(subject).to be_success + expect(subject.payload[:alerts].size).to eq(2) + expect(Gitlab::AppLogger).not_to have_received(:warn) + end + end + + context 'with feature flag globally enabled' do + before do + stub_feature_flags(prometheus_notify_max_alerts: true) + end + + include_examples 'process truncated alerts' + end + + context 'with feature flag enabled on project' do + before do + stub_feature_flags(prometheus_notify_max_alerts: project) + end + + include_examples 'process truncated alerts' + end + + context 'with feature flag enabled on unrelated project' do + let(:another_project) { create(:project) } + + before do + stub_feature_flags(prometheus_notify_max_alerts: another_project) + end + + include_examples 'process all alerts' + end + + context 'with feature flag disabled' do + before do + stub_feature_flags(prometheus_notify_max_alerts: false) + end + + include_examples 'process all alerts' + end + end end context 'with invalid payload' do diff --git a/spec/services/projects/repository_languages_service_spec.rb b/spec/services/projects/repository_languages_service_spec.rb index cb61a7a1a3e..50d5fba6b84 100644 --- a/spec/services/projects/repository_languages_service_spec.rb +++ b/spec/services/projects/repository_languages_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Projects::RepositoryLanguagesService do - let(:service) { described_class.new(project, project.owner) } + let(:service) { described_class.new(project, project.first_owner) } context 'when detected_repository_languages flag is set' do let(:project) { create(:project) } diff --git a/spec/services/projects/update_pages_configuration_service_spec.rb b/spec/services/projects/update_pages_configuration_service_spec.rb deleted file mode 100644 index 58939ef4ada..00000000000 --- a/spec/services/projects/update_pages_configuration_service_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::UpdatePagesConfigurationService do - let(:service) { described_class.new(project) } - - describe "#execute" do - subject { service.execute } - - context 'when pages are deployed' do - let_it_be(:project) do - create(:project).tap(&:mark_pages_as_deployed) - end - - let(:file) { Tempfile.new('pages-test') } - - before do - allow(service).to receive(:pages_config_file).and_return(file.path) - end - - after do - file.close - file.unlink - end - - context 'when configuration changes' do - it 'updates the config and reloads the daemon' do - expect(service).to receive(:update_file).with(file.path, an_instance_of(String)) - .and_call_original - allow(service).to receive(:update_file).with(File.join(::Settings.pages.path, '.update'), - an_instance_of(String)).and_call_original - - expect(subject).to include(status: :success) - end - - it "doesn't update configuration files if updates on legacy storage are disabled" do - allow(Settings.pages.local_store).to receive(:enabled).and_return(false) - - expect(service).not_to receive(:update_file) - - expect(subject).to include(status: :success) - end - end - - context 'when configuration does not change' do - before do - # we set the configuration - service.execute - end - - it 'does not update anything' do - expect(service).not_to receive(:update_file) - - expect(subject).to include(status: :success) - end - end - end - - context 'when pages are not deployed' do - let_it_be(:project) do - create(:project).tap(&:mark_pages_as_not_deployed) - end - - it 'returns successfully' do - expect(subject).to eq(status: :success) - end - - it 'does not update the config' do - expect(service).not_to receive(:update_file) - - subject - end - end - end -end diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb index f4a6d1b19e7..547641867bc 100644 --- a/spec/services/projects/update_remote_mirror_service_spec.rb +++ b/spec/services/projects/update_remote_mirror_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Projects::UpdateRemoteMirrorService do subject(:execute!) { service.execute(remote_mirror, retries) } before do - project.repository.add_branch(project.owner, 'existing-branch', 'master') + project.repository.add_branch(project.first_owner, 'existing-branch', 'master') allow(remote_mirror) .to receive(:update_repository) @@ -131,32 +131,82 @@ RSpec.describe Projects::UpdateRemoteMirrorService do expect_next_instance_of(Lfs::PushService) do |service| expect(service).to receive(:execute) end + expect(Gitlab::AppJsonLogger).not_to receive(:info) execute! + + expect(remote_mirror.update_status).to eq('finished') + expect(remote_mirror.last_error).to be_nil end - it 'does nothing to an SSH repository' do - remote_mirror.update!(url: 'ssh://example.com') + context 'when LFS objects fail to push' do + before do + expect_next_instance_of(Lfs::PushService) do |service| + expect(service).to receive(:execute).and_return({ status: :error, message: 'unauthorized' }) + end + end + + context 'when remote_mirror_fail_on_lfs feature flag enabled' do + it 'fails update' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + hash_including(message: "Error synching remote mirror")).and_call_original - expect_any_instance_of(Lfs::PushService).not_to receive(:execute) + execute! - execute! - end + expect(remote_mirror.update_status).to eq('failed') + expect(remote_mirror.last_error).to eq("Error synchronizing LFS files:\n\nunauthorized\n\n") + end + end - it 'does nothing if LFS is disabled' do - expect(project).to receive(:lfs_enabled?) { false } + context 'when remote_mirror_fail_on_lfs feature flag is disabled' do + before do + stub_feature_flags(remote_mirror_fail_on_lfs: false) + end - expect_any_instance_of(Lfs::PushService).not_to receive(:execute) + it 'does not fail update' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + hash_including(message: "Error synching remote mirror")).and_call_original - execute! + execute! + + expect(remote_mirror.update_status).to eq('finished') + expect(remote_mirror.last_error).to be_nil + end + end end - it 'does nothing if non-password auth is specified' do - remote_mirror.update!(auth_method: 'ssh_public_key') + context 'with SSH repository' do + let(:ssh_mirror) { create(:remote_mirror, project: project, enabled: true) } + + before do + allow(ssh_mirror) + .to receive(:update_repository) + .and_return(double(divergent_refs: [])) + end + + it 'does nothing to an SSH repository' do + ssh_mirror.update!(url: 'ssh://example.com') - expect_any_instance_of(Lfs::PushService).not_to receive(:execute) + expect_any_instance_of(Lfs::PushService).not_to receive(:execute) - execute! + service.execute(ssh_mirror, retries) + end + + it 'does nothing if LFS is disabled' do + expect(project).to receive(:lfs_enabled?) { false } + + expect_any_instance_of(Lfs::PushService).not_to receive(:execute) + + service.execute(ssh_mirror, retries) + end + + it 'does nothing if non-password auth is specified' do + ssh_mirror.update!(auth_method: 'ssh_public_key') + + expect_any_instance_of(Lfs::PushService).not_to receive(:execute) + + service.execute(ssh_mirror, retries) + end end end end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 4923ef169e8..7b5bf1db030 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -149,7 +149,7 @@ RSpec.describe Projects::UpdateService do describe 'when updating project that has forks' do let(:project) { create(:project, :internal) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:forked_project) { fork_project(project) } context 'and unlink forks feature flag is off' do @@ -379,52 +379,6 @@ RSpec.describe Projects::UpdateService do end end - shared_examples 'updating pages configuration' do - it 'schedules the `PagesUpdateConfigurationWorker` when pages are deployed' do - project.mark_pages_as_deployed - - expect(PagesUpdateConfigurationWorker).to receive(:perform_async).with(project.id) - - subject - end - - it "does not schedule a job when pages aren't deployed" do - project.mark_pages_as_not_deployed - - expect(PagesUpdateConfigurationWorker).not_to receive(:perform_async).with(project.id) - - subject - 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_behaves_like 'updating pages configuration' - end - - context 'when updating #pages_access_level' do - subject(:call_service) do - update_project(project, admin, project_feature_attributes: { pages_access_level: ProjectFeature::ENABLED }) - end - - it 'updates the attribute' do - expect { call_service } - .to change { project.project_feature.pages_access_level } - .to(ProjectFeature::ENABLED) - end - - it_behaves_like 'updating pages configuration' - end - context 'when updating #emails_disabled' do it 'updates the attribute for the project owner' do expect { update_project(project, user, emails_disabled: true) } diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb index 756c775be9b..0bea3edf203 100644 --- a/spec/services/protected_branches/create_service_spec.rb +++ b/spec/services/protected_branches/create_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe ProtectedBranches::CreateService do let(:project) { create(:project) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:params) do { name: name, diff --git a/spec/services/protected_branches/destroy_service_spec.rb b/spec/services/protected_branches/destroy_service_spec.rb index 47a048e7033..4e55c72f312 100644 --- a/spec/services/protected_branches/destroy_service_spec.rb +++ b/spec/services/protected_branches/destroy_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe ProtectedBranches::DestroyService do let(:protected_branch) { create(:protected_branch) } let(:project) { protected_branch.project } - let(:user) { project.owner } + let(:user) { project.first_owner } describe '#execute' do subject(:service) { described_class.new(project, user) } diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb index b5cf1a54aff..3d9b77dcfc0 100644 --- a/spec/services/protected_branches/update_service_spec.rb +++ b/spec/services/protected_branches/update_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe ProtectedBranches::UpdateService do let(:protected_branch) { create(:protected_branch) } let(:project) { protected_branch.project } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:params) { { name: new_name } } describe '#execute' do diff --git a/spec/services/protected_tags/create_service_spec.rb b/spec/services/protected_tags/create_service_spec.rb index 3d06cc9fb6c..31059d17f10 100644 --- a/spec/services/protected_tags/create_service_spec.rb +++ b/spec/services/protected_tags/create_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe ProtectedTags::CreateService do let(:project) { create(:project) } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:params) do { name: name, diff --git a/spec/services/protected_tags/destroy_service_spec.rb b/spec/services/protected_tags/destroy_service_spec.rb index fbd1452a8d1..658a4f5557e 100644 --- a/spec/services/protected_tags/destroy_service_spec.rb +++ b/spec/services/protected_tags/destroy_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe ProtectedTags::DestroyService do let(:protected_tag) { create(:protected_tag) } let(:project) { protected_tag.project } - let(:user) { project.owner } + let(:user) { project.first_owner } describe '#execute' do subject(:service) { described_class.new(project, user) } diff --git a/spec/services/protected_tags/update_service_spec.rb b/spec/services/protected_tags/update_service_spec.rb index 22005bb9b89..8d301dcd825 100644 --- a/spec/services/protected_tags/update_service_spec.rb +++ b/spec/services/protected_tags/update_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe ProtectedTags::UpdateService do let(:protected_tag) { create(:protected_tag) } let(:project) { protected_tag.project } - let(:user) { project.owner } + let(:user) { project.first_owner } let(:params) { { name: new_name } } describe '#execute' do diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 77d263f4b70..e56e54db6f4 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe QuickActions::InterpretService do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, :crm_enabled) } let_it_be(:public_project) { create(:project, :public, group: group) } let_it_be(:repository_project) { create(:project, :repository) } let_it_be(:project) { public_project } diff --git a/spec/services/resource_access_tokens/create_service_spec.rb b/spec/services/resource_access_tokens/create_service_spec.rb index 42520ea26b2..5a88929334b 100644 --- a/spec/services/resource_access_tokens/create_service_spec.rb +++ b/spec/services/resource_access_tokens/create_service_spec.rb @@ -7,10 +7,14 @@ RSpec.describe ResourceAccessTokens::CreateService do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :private) } + let_it_be(:group) { create(:group, :private) } let_it_be(:params) { {} } + before do + stub_config_setting(host: 'example.com') + end + describe '#execute' do - # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046 shared_examples 'token creation fails' do let(:resource) { create(:project)} @@ -31,7 +35,7 @@ RSpec.describe ResourceAccessTokens::CreateService do access_token = response.payload[:access_token] - expect(access_token.user.reload.user_type).to eq("#{resource_type}_bot") + expect(access_token.user.reload.user_type).to eq("project_bot") expect(access_token.user.created_by_id).to eq(user.id) end @@ -88,6 +92,15 @@ RSpec.describe ResourceAccessTokens::CreateService do end end + context 'bot email' do + it 'check email domain' do + response = subject + access_token = response.payload[:access_token] + + expect(access_token.user.email).to end_with("@noreply.#{Gitlab.config.gitlab.host}") + end + end + context 'access level' do context 'when user does not specify an access level' do it 'adds the bot user as a maintainer in the resource' do @@ -112,10 +125,8 @@ RSpec.describe ResourceAccessTokens::CreateService do end context 'when user is external' do - let(:user) { create(:user, :external) } - before do - project.add_maintainer(user) + user.update!(external: true) end it 'creates resource bot user with external status' do @@ -162,7 +173,7 @@ RSpec.describe ResourceAccessTokens::CreateService do access_token = response.payload[:access_token] project_bot = access_token.user - expect(project.members.find_by(user_id: project_bot.id).expires_at).to eq(nil) + expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(nil) end end end @@ -183,7 +194,7 @@ RSpec.describe ResourceAccessTokens::CreateService do access_token = response.payload[:access_token] project_bot = access_token.user - expect(project.members.find_by(user_id: project_bot.id).expires_at).to eq(params[:expires_at]) + expect(resource.members.find_by(user_id: project_bot.id).expires_at).to eq(params[:expires_at]) end end end @@ -234,24 +245,41 @@ RSpec.describe ResourceAccessTokens::CreateService do end end + shared_examples 'when user does not have permission to create a resource bot' do + it_behaves_like 'token creation fails' + + it 'returns the permission error message' do + response = subject + + expect(response.error?).to be true + expect(response.errors).to include("User does not have permission to create #{resource_type} access token") + end + end + context 'when resource is a project' do let_it_be(:resource_type) { 'project' } let_it_be(:resource) { project } - context 'when user does not have permission to create a resource bot' do - it_behaves_like 'token creation fails' - - it 'returns the permission error message' do - response = subject + it_behaves_like 'when user does not have permission to create a resource bot' - expect(response.error?).to be true - expect(response.errors).to include("User does not have permission to create #{resource_type} access token") + context 'user with valid permission' do + before_all do + resource.add_maintainer(user) end + + it_behaves_like 'allows creation of bot with valid params' end + end + + context 'when resource is a project' do + let_it_be(:resource_type) { 'group' } + let_it_be(:resource) { group } + + it_behaves_like 'when user does not have permission to create a resource bot' context 'user with valid permission' do before_all do - resource.add_maintainer(user) + resource.add_owner(user) end it_behaves_like 'allows creation of bot with valid params' diff --git a/spec/services/resource_access_tokens/revoke_service_spec.rb b/spec/services/resource_access_tokens/revoke_service_spec.rb index 4f4e2ab0c99..3d724a79fef 100644 --- a/spec/services/resource_access_tokens/revoke_service_spec.rb +++ b/spec/services/resource_access_tokens/revoke_service_spec.rb @@ -6,11 +6,12 @@ RSpec.describe ResourceAccessTokens::RevokeService do subject { described_class.new(user, resource, access_token).execute } let_it_be(:user) { create(:user) } + let_it_be(:user_non_priviledged) { create(:user) } + let_it_be(:resource_bot) { create(:user, :project_bot) } let(:access_token) { create(:personal_access_token, user: resource_bot) } describe '#execute', :sidekiq_inline do - # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046 shared_examples 'revokes access token' do it { expect(subject.success?).to be true } @@ -79,71 +80,80 @@ RSpec.describe ResourceAccessTokens::RevokeService do end end - context 'when resource is a project' do - let_it_be(:resource) { create(:project, :private) } + shared_examples 'revoke fails' do |resource_type| + let_it_be(:other_user) { create(:user) } - let(:resource_bot) { create(:user, :project_bot) } + context "when access token does not belong to this #{resource_type}" do + it 'does not find the bot' do + other_access_token = create(:personal_access_token, user: other_user) - before do - resource.add_maintainer(user) - resource.add_maintainer(resource_bot) - end + response = described_class.new(user, resource, other_access_token).execute - it_behaves_like 'revokes access token' + expect(response.success?).to be false + expect(response.message).to eq("Failed to find bot user") + expect(access_token.reload.revoked?).to be false + end + end - context 'revoke fails' do - let_it_be(:other_user) { create(:user) } + context 'when user does not have permission to destroy bot' do + context "when non-#{resource_type} member tries to delete project bot" do + it 'does not allow other user to delete bot' do + response = described_class.new(other_user, resource, access_token).execute - context 'when access token does not belong to this project' do - it 'does not find the bot' do - other_access_token = create(:personal_access_token, user: other_user) + expect(response.success?).to be false + expect(response.message).to eq("#{other_user.name} cannot delete #{access_token.user.name}") + expect(access_token.reload.revoked?).to be false + end + end - response = described_class.new(user, resource, other_access_token).execute + context "when non-priviledged #{resource_type} member tries to delete project bot" do + it 'does not allow developer to delete bot' do + response = described_class.new(user_non_priviledged, resource, access_token).execute expect(response.success?).to be false - expect(response.message).to eq("Failed to find bot user") + expect(response.message).to eq("#{user_non_priviledged.name} cannot delete #{access_token.user.name}") expect(access_token.reload.revoked?).to be false end end + end - context 'when user does not have permission to destroy bot' do - context 'when non-project member tries to delete project bot' do - it 'does not allow other user to delete bot' do - response = described_class.new(other_user, resource, access_token).execute - - expect(response.success?).to be false - expect(response.message).to eq("#{other_user.name} cannot delete #{access_token.user.name}") - expect(access_token.reload.revoked?).to be false - end + context 'when deletion of bot user fails' do + before do + allow_next_instance_of(::ResourceAccessTokens::RevokeService) do |service| + allow(service).to receive(:execute).and_return(false) end + end + + it_behaves_like 'rollback revoke steps' + end + end - context 'when non-maintainer project member tries to delete project bot' do - let(:developer) { create(:user) } + context 'when resource is a project' do + let_it_be(:resource) { create(:project, :private) } - before do - resource.add_developer(developer) - end + before do + resource.add_maintainer(user) + resource.add_developer(user_non_priviledged) + resource.add_maintainer(resource_bot) + end - it 'does not allow developer to delete bot' do - response = described_class.new(developer, resource, access_token).execute + it_behaves_like 'revokes access token' - expect(response.success?).to be false - expect(response.message).to eq("#{developer.name} cannot delete #{access_token.user.name}") - expect(access_token.reload.revoked?).to be false - end - end - end + it_behaves_like 'revoke fails', 'project' + end - context 'when deletion of bot user fails' do - before do - allow_next_instance_of(::ResourceAccessTokens::RevokeService) do |service| - allow(service).to receive(:execute).and_return(false) - end - end + context 'when resource is a group' do + let_it_be(:resource) { create(:group, :private) } - it_behaves_like 'rollback revoke steps' - end + before do + resource.add_owner(user) + resource.add_maintainer(user_non_priviledged) + resource.add_maintainer(resource_bot) end + + it_behaves_like 'revokes access token' + + it_behaves_like 'revoke fails', 'group' end end end diff --git a/spec/services/service_ping/submit_service_ping_service_spec.rb b/spec/services/service_ping/submit_service_ping_service_spec.rb index ca387690e83..2971c9a9309 100644 --- a/spec/services/service_ping/submit_service_ping_service_spec.rb +++ b/spec/services/service_ping/submit_service_ping_service_spec.rb @@ -110,6 +110,7 @@ RSpec.describe ServicePing::SubmitService do context 'when product_intelligence_enabled is true' do before do stub_usage_data_connections + stub_database_flavor_check allow(ServicePing::ServicePingSettings).to receive(:product_intelligence_enabled?).and_return(true) end @@ -126,6 +127,7 @@ RSpec.describe ServicePing::SubmitService do context 'when usage ping is enabled' do before do stub_usage_data_connections + stub_database_flavor_check stub_application_setting(usage_ping_enabled: true) end diff --git a/spec/services/test_hooks/system_service_spec.rb b/spec/services/test_hooks/system_service_spec.rb index a13ae471b4b..48c8c24212a 100644 --- a/spec/services/test_hooks/system_service_spec.rb +++ b/spec/services/test_hooks/system_service_spec.rb @@ -9,7 +9,7 @@ RSpec.describe TestHooks::SystemService do let_it_be(:project) { create(:project, :repository) } let(:hook) { create(:system_hook) } - let(:service) { described_class.new(hook, project.owner, trigger) } + let(:service) { described_class.new(hook, project.first_owner, trigger) } let(:success_result) { { status: :success, http_status: 200, message: 'ok' } } before do diff --git a/spec/services/users/create_service_spec.rb b/spec/services/users/create_service_spec.rb index 74340bac055..ab9da82e91c 100644 --- a/spec/services/users/create_service_spec.rb +++ b/spec/services/users/create_service_spec.rb @@ -12,7 +12,7 @@ RSpec.describe Users::CreateService do context 'when required parameters are provided' do let(:params) do - { name: 'John Doe', username: 'jduser', email: email, password: 'mydummypass' } + { name: 'John Doe', username: 'jduser', email: email, password: Gitlab::Password.test_default } end it 'returns a persisted user' do @@ -82,13 +82,13 @@ RSpec.describe Users::CreateService do context 'when force_random_password parameter is true' do let(:params) do - { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass', force_random_password: true } + { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: Gitlab::Password.test_default, force_random_password: true } end it 'generates random password' do user = service.execute - expect(user.password).not_to eq 'mydummypass' + expect(user.password).not_to eq Gitlab::Password.test_default expect(user.password).to be_present end end @@ -99,7 +99,7 @@ RSpec.describe Users::CreateService do name: 'John Doe', username: 'jduser', email: 'jd@example.com', - password: 'mydummypass', + password: Gitlab::Password.test_default, password_automatically_set: true } end @@ -121,7 +121,7 @@ RSpec.describe Users::CreateService do context 'when skip_confirmation parameter is true' do let(:params) do - { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass', skip_confirmation: true } + { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: Gitlab::Password.test_default, skip_confirmation: true } end it 'confirms the user' do @@ -131,7 +131,7 @@ RSpec.describe Users::CreateService do context 'when reset_password parameter is true' do let(:params) do - { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass', reset_password: true } + { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: Gitlab::Password.test_default, reset_password: true } end it 'resets password even if a password parameter is given' do @@ -152,7 +152,7 @@ RSpec.describe Users::CreateService do context 'with nil user' do let(:params) do - { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: 'mydummypass', skip_confirmation: true } + { name: 'John Doe', username: 'jduser', email: 'jd@example.com', password: Gitlab::Password.test_default, skip_confirmation: true } end let(:service) { described_class.new(nil, params) } diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index aa4df93a241..a31902c7f16 100644 --- a/spec/services/users/refresh_authorized_projects_service_spec.rb +++ b/spec/services/users/refresh_authorized_projects_service_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Users::RefreshAuthorizedProjectsService do # triggered twice. let!(:project) { create(:project) } - let(:user) { project.namespace.owner } + let(:user) { project.namespace.first_owner } let(:service) { described_class.new(user) } describe '#execute', :clean_gitlab_redis_shared_state do diff --git a/spec/services/users/upsert_credit_card_validation_service_spec.rb b/spec/services/users/upsert_credit_card_validation_service_spec.rb index 952d482f1bd..ac7e619612f 100644 --- a/spec/services/users/upsert_credit_card_validation_service_spec.rb +++ b/spec/services/users/upsert_credit_card_validation_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Users::UpsertCreditCardValidationService do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, requires_credit_card_verification: true) } let(:user_id) { user.id } let(:credit_card_validated_time) { Time.utc(2020, 1, 1) } @@ -21,7 +21,7 @@ RSpec.describe Users::UpsertCreditCardValidationService do end describe '#execute' do - subject(:service) { described_class.new(params) } + subject(:service) { described_class.new(params, user) } context 'successfully set credit card validation record for the user' do context 'when user does not have credit card validation record' do @@ -42,6 +42,10 @@ RSpec.describe Users::UpsertCreditCardValidationService do expiration_date: Date.new(expiration_year, 1, 31) ) end + + it 'sets the requires_credit_card_verification attribute on the user to false' do + expect { service.execute }.to change { user.reload.requires_credit_card_verification }.to(false) + end end context 'when user has credit card validation record' do diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb index 2a3b3814065..42f7ebc85f9 100644 --- a/spec/services/verify_pages_domain_service_spec.rb +++ b/spec/services/verify_pages_domain_service_spec.rb @@ -269,56 +269,6 @@ RSpec.describe VerifyPagesDomainService do end end - context 'pages configuration updates' do - context 'enabling a disabled domain' do - let(:domain) { create(:pages_domain, :disabled) } - - it 'schedules an update' do - stub_resolver(domain.domain => domain.verification_code) - - expect(domain).to receive(:update_daemon) - - service.execute - end - end - - context 'verifying an enabled domain' do - let(:domain) { create(:pages_domain) } - - it 'schedules an update' do - stub_resolver(domain.domain => domain.verification_code) - - expect(domain).not_to receive(:update_daemon) - - service.execute - end - end - - context 'disabling an expired domain' do - let(:domain) { create(:pages_domain, :expired) } - - it 'schedules an update' do - stub_resolver - - expect(domain).to receive(:update_daemon) - - service.execute - end - end - - context 'failing to verify a disabled domain' do - let(:domain) { create(:pages_domain, :disabled) } - - it 'does not schedule an update' do - stub_resolver - - expect(domain).not_to receive(:update_daemon) - - service.execute - end - end - end - context 'no verification code' do let(:domain) { create(:pages_domain) } diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 2aebd2adab9..7d933ea9c5c 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -2,20 +2,12 @@ require 'spec_helper' -RSpec.describe WebHookService do +RSpec.describe WebHookService, :request_store, :clean_gitlab_redis_shared_state do include StubRequests let_it_be(:project) { create(:project) } let_it_be_with_reload(:project_hook) { create(:project_hook, project: project) } - let(:headers) do - { - 'Content-Type' => 'application/json', - 'User-Agent' => "GitLab/#{Gitlab::VERSION}", - 'X-Gitlab-Event' => 'Push Hook' - } - end - let(:data) do { before: 'oldrev', after: 'newrev', ref: 'ref' } end @@ -61,6 +53,21 @@ RSpec.describe WebHookService do end describe '#execute' do + let!(:uuid) { SecureRandom.uuid } + let(:headers) do + { + 'Content-Type' => 'application/json', + 'User-Agent' => "GitLab/#{Gitlab::VERSION}", + 'X-Gitlab-Event' => 'Push Hook', + 'X-Gitlab-Event-UUID' => uuid + } + end + + before do + # Set a stable value for the `X-Gitlab-Event-UUID` header. + Gitlab::WebHooks::RecursionDetection.set_request_uuid(uuid) + end + context 'when token is defined' do let_it_be(:project_hook) { create(:project_hook, :token) } @@ -127,11 +134,74 @@ RSpec.describe WebHookService do expect(service_instance.execute).to eq({ status: :error, message: 'Hook disabled' }) end + it 'executes and registers the hook with the recursion detection', :aggregate_failures do + stub_full_request(project_hook.url, method: :post) + cache_key = Gitlab::WebHooks::RecursionDetection.send(:cache_key_for_hook, project_hook) + + ::Gitlab::Redis::SharedState.with do |redis| + expect { service_instance.execute }.to change { + redis.sismember(cache_key, project_hook.id) + }.to(true) + end + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with(headers: headers) + .once + end + + it 'executes and logs if a recursive web hook is detected', :aggregate_failures do + stub_full_request(project_hook.url, method: :post) + Gitlab::WebHooks::RecursionDetection.register!(project_hook) + + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: project_hook.id, + hook_type: 'ProjectHook', + hook_name: 'push_hooks', + recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook), + 'correlation_id' => kind_of(String) + ) + ) + + service_instance.execute + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with(headers: headers) + .once + end + + it 'executes and logs if the recursion count limit would be exceeded', :aggregate_failures do + stub_full_request(project_hook.url, method: :post) + stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3) + previous_hooks = create_list(:project_hook, 3) + previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) } + + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: project_hook.id, + hook_type: 'ProjectHook', + hook_name: 'push_hooks', + recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook), + 'correlation_id' => kind_of(String) + ) + ) + + service_instance.execute + + expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)) + .with(headers: headers) + .once + end + it 'handles exceptions' do exceptions = Gitlab::HTTP::HTTP_ERRORS + [ Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError ] + allow(Gitlab::WebHooks::RecursionDetection).to receive(:block?).and_return(false) + exceptions.each do |exception_class| exception = exception_class.new('Exception message') project_hook.enable! @@ -420,6 +490,57 @@ RSpec.describe WebHookService do end end + context 'recursion detection' do + before do + # Set a request UUID so `RecursionDetection.block?` will query redis. + Gitlab::WebHooks::RecursionDetection.set_request_uuid(SecureRandom.uuid) + end + + it 'queues a worker and logs an error if the call chain limit would be exceeded' do + stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3) + previous_hooks = create_list(:project_hook, 3) + previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) } + + expect(WebHookWorker).to receive(:perform_async) + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: project_hook.id, + hook_type: 'ProjectHook', + hook_name: 'push_hooks', + recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook), + 'correlation_id' => kind_of(String), + 'meta.project' => project.full_path, + 'meta.related_class' => 'ProjectHook', + 'meta.root_namespace' => project.root_namespace.full_path + ) + ) + + service_instance.async_execute + end + + it 'queues a worker and logs an error if a recursive call chain is detected' do + Gitlab::WebHooks::RecursionDetection.register!(project_hook) + + expect(WebHookWorker).to receive(:perform_async) + expect(Gitlab::AuthLogger).to receive(:error).with( + include( + message: 'Webhook recursion detected and will be blocked in future', + hook_id: project_hook.id, + hook_type: 'ProjectHook', + hook_name: 'push_hooks', + recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook), + 'correlation_id' => kind_of(String), + 'meta.project' => project.full_path, + 'meta.related_class' => 'ProjectHook', + 'meta.root_namespace' => project.root_namespace.full_path + ) + ) + + service_instance.async_execute + end + end + context 'when hook has custom context attributes' do it 'includes the attributes in the worker context' do expect(WebHookWorker).to receive(:perform_async) do diff --git a/spec/services/work_items/build_service_spec.rb b/spec/services/work_items/build_service_spec.rb new file mode 100644 index 00000000000..6b2e2d8819e --- /dev/null +++ b/spec/services/work_items/build_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::BuildService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:guest) { create(:user) } + + let(:user) { guest } + + before_all do + project.add_guest(guest) + end + + describe '#execute' do + subject { described_class.new(project: project, current_user: user, params: {}).execute } + + it { is_expected.to be_a(::WorkItem) } + end +end diff --git a/spec/services/work_items/create_service_spec.rb b/spec/services/work_items/create_service_spec.rb new file mode 100644 index 00000000000..2c054ae59a0 --- /dev/null +++ b/spec/services/work_items/create_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::CreateService do + include AfterNextHelpers + + let_it_be(:group) { create(:group) } + let_it_be_with_reload(:project) { create(:project, group: group) } + let_it_be(:user) { create(:user) } + + let(:spam_params) { double } + + describe '#execute' do + let(:work_item) { described_class.new(project: project, current_user: user, params: opts, spam_params: spam_params).execute } + + before do + stub_spam_services + end + + context 'when params are valid' do + before_all do + project.add_guest(user) + end + + let(:opts) do + { + title: 'Awesome work_item', + description: 'please fix' + } + end + + it 'created instance is a WorkItem' do + expect(Issuable::CommonSystemNotesService).to receive_message_chain(:new, :execute) + + expect(work_item).to be_persisted + expect(work_item).to be_a(::WorkItem) + expect(work_item.title).to eq('Awesome work_item') + expect(work_item.description).to eq('please fix') + expect(work_item.work_item_type.base_type).to eq('issue') + end + end + + context 'checking spam' do + let(:params) do + { + title: 'Spam work_item' + } + end + + subject do + described_class.new(project: project, current_user: user, params: params, spam_params: spam_params) + end + + it 'executes SpamActionService' do + expect_next_instance_of( + Spam::SpamActionService, + { + spammable: kind_of(WorkItem), + spam_params: spam_params, + user: an_instance_of(User), + action: :create + } + ) do |instance| + expect(instance).to receive(:execute) + end + + subject.execute + end + end + end +end diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb index a5efc8348a4..da4a0e8da80 100644 --- a/spec/simplecov_env.rb +++ b/spec/simplecov_env.rb @@ -53,7 +53,6 @@ module SimpleCovEnv track_files '{app,config/initializers,config/initializers_before_autoloader,db/post_migrate,haml_lint,lib,rubocop,tooling}/**/*.rb' add_filter '/vendor/ruby/' - add_filter '/app/controllers/sherlock/' # Profiling tool used only in development add_filter '/bin/' add_filter 'db/fixtures/development/' # Matches EE files as well diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c497f8245fe..6d5036365e1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -290,15 +290,9 @@ RSpec.configure do |config| stub_feature_flags(diffs_virtual_scrolling: false) - # The following `vue_issues_list`/`vue_issuables_list` stubs can be removed + # The following `vue_issues_list` stub can be removed # once the Vue issues page has feature parity with the current Haml page stub_feature_flags(vue_issues_list: false) - stub_feature_flags(vue_issuables_list: false) - - # Disable `refactor_blob_viewer` as we refactor - # the blob viewer. See the follwing epic for more: - # https://gitlab.com/groups/gitlab-org/-/epics/5531 - stub_feature_flags(refactor_blob_viewer: false) # Disable `main_branch_over_master` as we migrate # from `master` to `main` accross our codebase. @@ -459,10 +453,23 @@ RSpec.configure do |config| end end + # Ensures that any Javascript script that tries to make the external VersionCheck API call skips it and returns a response + config.before(:each, :js) do + allow_any_instance_of(VersionCheck).to receive(:response).and_return({ "severity" => "success" }) + end + config.after(:each, :silence_stdout) do $stdout = STDOUT end + config.around(:each, stubbing_settings_source: true) do |example| + original_instance = ::Settings.instance_variable_get(:@instance) + + example.run + + ::Settings.instance_variable_set(:@instance, original_instance) + end + config.disable_monkey_patching! end diff --git a/spec/support/database/cross-database-modification-allowlist.yml b/spec/support/database/cross-database-modification-allowlist.yml index d6e74349069..fe51488c706 100644 --- a/spec/support/database/cross-database-modification-allowlist.yml +++ b/spec/support/database/cross-database-modification-allowlist.yml @@ -1,31 +1 @@ -- "./ee/spec/mailers/notify_spec.rb" -- "./ee/spec/models/group_member_spec.rb" -- "./ee/spec/replicators/geo/terraform_state_version_replicator_spec.rb" -- "./ee/spec/services/ci/retry_build_service_spec.rb" -- "./spec/controllers/abuse_reports_controller_spec.rb" -- "./spec/controllers/omniauth_callbacks_controller_spec.rb" -- "./spec/controllers/projects/issues_controller_spec.rb" -- "./spec/features/issues/issue_detail_spec.rb" -- "./spec/features/projects/pipelines/pipeline_spec.rb" -- "./spec/features/signed_commits_spec.rb" -- "./spec/helpers/issuables_helper_spec.rb" -- "./spec/lib/gitlab/auth_spec.rb" -- "./spec/lib/gitlab/ci/pipeline/chain/create_spec.rb" -- "./spec/lib/gitlab/email/handler/create_issue_handler_spec.rb" -- "./spec/lib/gitlab/email/handler/create_merge_request_handler_spec.rb" -- "./spec/lib/gitlab/email/handler/create_note_handler_spec.rb" -- "./spec/lib/gitlab/email/handler/create_note_on_issuable_handler_spec.rb" -- "./spec/models/ci/build_trace_chunk_spec.rb" -- "./spec/models/ci/job_artifact_spec.rb" -- "./spec/models/ci/runner_spec.rb" -- "./spec/models/clusters/applications/runner_spec.rb" -- "./spec/models/design_management/version_spec.rb" -- "./spec/models/hooks/system_hook_spec.rb" -- "./spec/models/members/project_member_spec.rb" -- "./spec/models/user_spec.rb" -- "./spec/models/user_status_spec.rb" -- "./spec/requests/api/commits_spec.rb" -- "./spec/services/ci/retry_build_service_spec.rb" -- "./spec/services/projects/overwrite_project_service_spec.rb" -- "./spec/workers/merge_requests/create_pipeline_worker_spec.rb" -- "./spec/workers/repository_cleanup_worker_spec.rb" +[] diff --git a/spec/support/db_cleaner.rb b/spec/support/db_cleaner.rb index 316d645f99f..fb70f82ef87 100644 --- a/spec/support/db_cleaner.rb +++ b/spec/support/db_cleaner.rb @@ -67,7 +67,7 @@ module DbCleaner # Migrate each database individually with_reestablished_active_record_base do all_connection_classes.each do |connection_class| - ActiveRecord::Base.establish_connection(connection_class.connection_db_config) + ActiveRecord::Base.establish_connection(connection_class.connection_db_config) # rubocop: disable Database/EstablishConnection ActiveRecord::Tasks::DatabaseTasks.migrate end diff --git a/spec/support/flaky_tests.rb b/spec/support/flaky_tests.rb index 0c211af695d..5ce55c47aab 100644 --- a/spec/support/flaky_tests.rb +++ b/spec/support/flaky_tests.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true return unless ENV['CI'] -return unless ENV['SKIP_FLAKY_TESTS_AUTOMATICALLY'] == "true" +return if ENV['SKIP_FLAKY_TESTS_AUTOMATICALLY'] == "false" return if ENV['CI_MERGE_REQUEST_LABELS'].to_s.include?('pipeline:run-flaky-tests') require_relative '../../tooling/rspec_flaky/report' diff --git a/spec/support/gitlab_stubs/gitlab_ci.yml b/spec/support/gitlab_stubs/gitlab_ci.yml index f3755e52b2c..52ae36229a6 100644 --- a/spec/support/gitlab_stubs/gitlab_ci.yml +++ b/spec/support/gitlab_stubs/gitlab_ci.yml @@ -9,7 +9,7 @@ before_script: variables: DB_NAME: postgres -types: +stages: - test - deploy - notify @@ -36,7 +36,7 @@ staging: KEY1: value1 KEY2: value2 script: "cap deploy stating" - type: deploy + stage: deploy tags: - ruby - mysql @@ -46,7 +46,7 @@ staging: production: variables: DB_NAME: mysql - type: deploy + stage: deploy script: - cap deploy production - cap notify @@ -58,7 +58,7 @@ production: - /^deploy-.*$/ dockerhub: - type: notify + stage: notify script: "curl http://dockerhub/URL" tags: - ruby diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb index 722d484609c..70b794f7d82 100644 --- a/spec/support/helpers/cycle_analytics_helpers.rb +++ b/spec/support/helpers/cycle_analytics_helpers.rb @@ -59,7 +59,7 @@ module CycleAnalyticsHelpers def save_value_stream(custom_value_stream_name) fill_in 'create-value-stream-name', with: custom_value_stream_name - page.find_button(s_('CreateValueStreamForm|Create Value Stream')).click + page.find_button(s_('CreateValueStreamForm|Create value stream')).click wait_for_requests end diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb index 923051a2e04..905c439f4d9 100644 --- a/spec/support/helpers/gitaly_setup.rb +++ b/spec/support/helpers/gitaly_setup.rb @@ -9,8 +9,13 @@ require 'securerandom' require 'socket' require 'logger' +require 'bundler' module GitalySetup + extend self + + REPOS_STORAGE = 'default' + LOGGER = begin default_name = ENV['CI'] ? 'DEBUG' : 'WARN' level_name = ENV['GITLAB_TESTING_LOG_LEVEL']&.upcase @@ -52,11 +57,13 @@ module GitalySetup def env { - 'HOME' => expand_path('tmp/tests'), 'GEM_PATH' => Gem.path.join(':'), - 'BUNDLE_APP_CONFIG' => File.join(gemfile_dir, '.bundle'), 'BUNDLE_INSTALL_FLAGS' => nil, + 'BUNDLE_IGNORE_CONFIG' => '1', + 'BUNDLE_PATH' => bundle_path, 'BUNDLE_GEMFILE' => gemfile, + 'BUNDLE_JOBS' => '4', + 'BUNDLE_RETRY' => '3', 'RUBYOPT' => nil, # Git hooks can't run during tests as the internal API is not running. @@ -65,17 +72,20 @@ module GitalySetup } end - # rubocop:disable GitlabSecurity/SystemCommandInjection - def set_bundler_config - system('bundle config set --local jobs 4', chdir: gemfile_dir) - system('bundle config set --local retry 3', chdir: gemfile_dir) + def bundle_path + # Allow the user to override BUNDLE_PATH if they need to + return ENV['GITALY_TEST_BUNDLE_PATH'] if ENV['GITALY_TEST_BUNDLE_PATH'] if ENV['CI'] - bundle_path = expand_path('vendor/gitaly-ruby') - system('bundle', 'config', 'set', '--local', 'path', bundle_path, chdir: gemfile_dir) + expand_path('vendor/gitaly-ruby') + else + explicit_path = Bundler.configured_bundle_path.explicit_path + + return unless explicit_path + + expand_path(explicit_path) end end - # rubocop:enable GitlabSecurity/SystemCommandInjection def config_path(service) case service @@ -88,6 +98,10 @@ module GitalySetup end end + def repos_path(storage = REPOS_STORAGE) + Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path + end + def service_binary(service) case service when :gitaly, :gitaly2 @@ -97,16 +111,20 @@ module GitalySetup end end + def run_command(cmd, env: {}) + system(env, *cmd, exception: true, chdir: tmp_tests_gitaly_dir) + end + def install_gitaly_gems - system(env, "make #{tmp_tests_gitaly_dir}/.ruby-bundle", chdir: tmp_tests_gitaly_dir) # rubocop:disable GitlabSecurity/SystemCommandInjection + run_command(%W[make #{tmp_tests_gitaly_dir}/.ruby-bundle], env: env) end def build_gitaly - system(env.merge({ 'GIT_VERSION' => nil }), 'make all git', chdir: tmp_tests_gitaly_dir) # rubocop:disable GitlabSecurity/SystemCommandInjection + run_command(%w[make all git], env: env.merge('GIT_VERSION' => nil)) end - def start_gitaly - start(:gitaly) + def start_gitaly(toml = nil) + start(:gitaly, toml) end def start_gitaly2 @@ -117,14 +135,20 @@ module GitalySetup start(:praefect) end - def start(service) + def start(service, toml = nil) + toml ||= config_path(service) args = ["#{tmp_tests_gitaly_bin_dir}/#{service_binary(service)}"] args.push("-config") if service == :praefect - args.push(config_path(service)) + args.push(toml) + + # Ensure user configuration does not affect Git + # Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58776#note_547613780 + env = self.env.merge('HOME' => nil, 'XDG_CONFIG_HOME' => nil) + pid = spawn(env, *args, [:out, :err] => "log/#{service}-test.log") begin - try_connect!(service) + try_connect!(service, toml) rescue StandardError Process.kill('TERM', pid) raise @@ -161,29 +185,37 @@ module GitalySetup abort 'bundle check failed' unless system(env, 'bundle', 'check', out: out, chdir: gemfile_dir) end - def read_socket_path(service) + def connect_proc(toml) # This code needs to work in an environment where we cannot use bundler, # so we cannot easily use the toml-rb gem. This ad-hoc parser should be # good enough. - config_text = IO.read(config_path(service)) + config_text = IO.read(toml) config_text.lines.each do |line| - match_data = line.match(/^\s*socket_path\s*=\s*"([^"]*)"$/) + match_data = line.match(/^\s*(socket_path|listen_addr)\s*=\s*"([^"]*)"$/) - return match_data[1] if match_data + next unless match_data + + case match_data[1] + when 'socket_path' + return -> { UNIXSocket.new(match_data[2]) } + when 'listen_addr' + addr, port = match_data[2].split(':') + return -> { TCPSocket.new(addr, port.to_i) } + end end - raise "failed to find socket_path in #{config_path(service)}" + raise "failed to find socket_path or listen_addr in #{toml}" end - def try_connect!(service) + def try_connect!(service, toml) LOGGER.debug "Trying to connect to #{service}: " timeout = 20 delay = 0.1 - socket = read_socket_path(service) + connect = connect_proc(toml) Integer(timeout / delay).times do - UNIXSocket.new(socket) + connect.call LOGGER.debug " OK\n" return @@ -194,6 +226,128 @@ module GitalySetup LOGGER.warn " FAILED to connect to #{service}\n" - raise "could not connect to #{socket}" + raise "could not connect to #{service}" + end + + def gitaly_socket_path + Gitlab::GitalyClient.address(REPOS_STORAGE).delete_prefix('unix:') + end + + def gitaly_dir + socket_path = gitaly_socket_path + socket_path = File.expand_path(gitaly_socket_path) if expand_path_for_socket? + + File.dirname(socket_path) + end + + # Linux fails with "bind: invalid argument" if a UNIX socket path exceeds 108 characters: + # https://github.com/golang/go/issues/6895. We use absolute paths in CI to ensure + # that changes in the current working directory don't affect GRPC reconnections. + def expand_path_for_socket? + !!ENV['CI'] + end + + def setup_gitaly + unless ENV['CI'] + # In CI Gitaly is built in the setup-test-env job and saved in the + # artifacts. So when tests are started, there's no need to build Gitaly. + build_gitaly + end + + Gitlab::SetupHelper::Gitaly.create_configuration( + gitaly_dir, + { 'default' => repos_path }, + force: true, + options: { + prometheus_listen_addr: 'localhost:9236' + } + ) + Gitlab::SetupHelper::Gitaly.create_configuration( + gitaly_dir, + { 'default' => repos_path }, + force: true, + options: { + internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"), + gitaly_socket: "gitaly2.socket", + config_filename: "gitaly2.config.toml" + } + ) + Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true) + end + + def socket_path(service) + File.join(tmp_tests_gitaly_dir, "#{service}.socket") + end + + def praefect_socket_path + "unix:" + socket_path(:praefect) + end + + def stop(pid) + Process.kill('KILL', pid) + rescue Errno::ESRCH + # The process can already be gone if the test run was INTerrupted. + end + + def spawn_gitaly(toml = nil) + check_gitaly_config! + + pids = [] + + if toml + pids << start_gitaly(toml) + else + pids << start_gitaly + pids << start_gitaly2 + pids << start_praefect + end + + Kernel.at_exit do + # In CI, this function is called by scripts/gitaly-test-spawn, triggered + # in a before_script. Gitaly needs to remain running until the container + # is stopped. + next if ENV['CI'] + # In Workhorse tests (locally or in CI), this function is called by + # scripts/gitaly-test-spawn during `make test`. Gitaly needs to remain + # running until `make test` cleans it up. + next if ENV['GITALY_PID_FILE'] + + pids.each { |pid| stop(pid) } + end + rescue StandardError + raise gitaly_failure_message + end + + def gitaly_failure_message + message = "gitaly spawn failed\n\n" + + message += "- The `gitaly` binary does not exist: #{gitaly_binary}\n" unless File.exist?(gitaly_binary) + message += "- The `praefect` binary does not exist: #{praefect_binary}\n" unless File.exist?(praefect_binary) + message += "- The `git` binary does not exist: #{git_binary}\n" unless File.exist?(git_binary) + + message += "\nCheck log/gitaly-test.log for errors.\n" + + unless ci? + message += "\nIf binaries are missing, try running `make -C tmp/tests/gitaly build git.`\n" + message += "\nOtherwise, try running `rm -rf #{tmp_tests_gitaly_dir}`." + end + + message + end + + def git_binary + File.join(tmp_tests_gitaly_dir, "_build", "deps", "git", "install", "bin", "git") + end + + def gitaly_binary + File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly") + end + + def praefect_binary + File.join(tmp_tests_gitaly_dir, "_build", "bin", "praefect") + end + + def git_binary_exists? + File.exist?(git_binary) end end diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb index d9157fa7485..4e0e8dd96ee 100644 --- a/spec/support/helpers/login_helpers.rb +++ b/spec/support/helpers/login_helpers.rb @@ -95,7 +95,7 @@ module LoginHelpers visit new_user_session_path fill_in "user_login", with: user.email - fill_in "user_password", with: "12345678" + fill_in "user_password", with: Gitlab::Password.test_default check 'user_remember_me' if remember click_button "Sign in" diff --git a/spec/support/helpers/stub_gitlab_calls.rb b/spec/support/helpers/stub_gitlab_calls.rb index ae031f58bd4..c3459f7bc81 100644 --- a/spec/support/helpers/stub_gitlab_calls.rb +++ b/spec/support/helpers/stub_gitlab_calls.rb @@ -92,12 +92,7 @@ module StubGitlabCalls end def stub_commonmark_sourcepos_disabled - render_options = - if Feature.enabled?(:use_cmark_renderer, default_enabled: :yaml) - Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS_C - else - Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS_RUBY - end + render_options = Banzai::Filter::MarkdownEngines::CommonMark::RENDER_OPTIONS allow_any_instance_of(Banzai::Filter::MarkdownEngines::CommonMark) .to receive(:render_options) diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 5e86b08aa45..d49a14f7f5b 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -91,6 +91,12 @@ module StubObjectStorage **params) end + def stub_ci_secure_file_object_storage(**params) + stub_object_storage_uploader(config: Gitlab.config.ci_secure_files.object_store, + uploader: Ci::SecureFileUploader, + **params) + end + def stub_terraform_state_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store, uploader: Terraform::StateUploader, diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index d36bc4e3cb4..5c3ca92c4d0 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'parallel' +require_relative 'gitaly_setup' module TestEnv extend self @@ -93,7 +94,6 @@ module TestEnv }.freeze TMP_TEST_PATH = Rails.root.join('tmp', 'tests').freeze - REPOS_STORAGE = 'default' SECOND_STORAGE_PATH = Rails.root.join('tmp', 'tests', 'second_storage') SETUP_METHODS = %i[setup_gitaly setup_gitlab_shell setup_workhorse setup_factory_repo setup_forked_repo].freeze @@ -128,7 +128,7 @@ module TestEnv # Can be overriden def post_init - start_gitaly(gitaly_dir) + start_gitaly end # Clean /tmp/tests @@ -142,12 +142,15 @@ module TestEnv end FileUtils.mkdir_p( - Gitlab::GitalyClient::StorageSettings.allow_disk_access { TestEnv.repos_path } + Gitlab::GitalyClient::StorageSettings.allow_disk_access { GitalySetup.repos_path } ) FileUtils.mkdir_p(SECOND_STORAGE_PATH) FileUtils.mkdir_p(backup_path) FileUtils.mkdir_p(pages_path) FileUtils.mkdir_p(artifacts_path) + FileUtils.mkdir_p(lfs_path) + FileUtils.mkdir_p(terraform_state_path) + FileUtils.mkdir_p(packages_path) end def setup_gitlab_shell @@ -156,111 +159,28 @@ module TestEnv def setup_gitaly component_timed_setup('Gitaly', - install_dir: gitaly_dir, + install_dir: GitalySetup.gitaly_dir, version: Gitlab::GitalyClient.expected_server_version, - task: "gitlab:gitaly:test_install", - task_args: [gitaly_dir, repos_path, gitaly_url].compact) do - Gitlab::SetupHelper::Gitaly.create_configuration( - gitaly_dir, - { 'default' => repos_path }, - force: true, - options: { - prometheus_listen_addr: 'localhost:9236' - } - ) - Gitlab::SetupHelper::Gitaly.create_configuration( - gitaly_dir, - { 'default' => repos_path }, - force: true, - options: { - internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"), - gitaly_socket: "gitaly2.socket", - config_filename: "gitaly2.config.toml" - } - ) - Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true) - end - end - - def gitaly_socket_path - Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') - end - - def gitaly_dir - socket_path = gitaly_socket_path - socket_path = File.expand_path(gitaly_socket_path) if expand_path? - - File.dirname(socket_path) - end - - # Linux fails with "bind: invalid argument" if a UNIX socket path exceeds 108 characters: - # https://github.com/golang/go/issues/6895. We use absolute paths in CI to ensure - # that changes in the current working directory don't affect GRPC reconnections. - def expand_path? - !!ENV['CI'] + task: "gitlab:gitaly:clone", + fresh_install: ENV.key?('FORCE_GITALY_INSTALL'), + task_args: [GitalySetup.gitaly_dir, GitalySetup.repos_path, gitaly_url].compact) do + GitalySetup.setup_gitaly + end end - def start_gitaly(gitaly_dir) + def start_gitaly if ci? # Gitaly has been spawned outside this process already return end - spawn_script = Rails.root.join('scripts/gitaly-test-spawn').to_s - Bundler.with_original_env do - unless system(spawn_script) - message = 'gitaly spawn failed' - message += " (try `rm -rf #{gitaly_dir}` ?)" unless ci? - raise message - end - end - - gitaly_pid = Integer(File.read(TMP_TEST_PATH.join('gitaly.pid'))) - gitaly2_pid = Integer(File.read(TMP_TEST_PATH.join('gitaly2.pid'))) - praefect_pid = Integer(File.read(TMP_TEST_PATH.join('praefect.pid'))) - - Kernel.at_exit do - pids = [gitaly_pid, gitaly2_pid, praefect_pid] - pids.each { |pid| stop(pid) } - end - - wait('gitaly') - wait('praefect') - end - - def stop(pid) - Process.kill('KILL', pid) - rescue Errno::ESRCH - # The process can already be gone if the test run was INTerrupted. + GitalySetup.spawn_gitaly end def gitaly_url ENV.fetch('GITALY_REPO_URL', nil) end - def socket_path(service) - TMP_TEST_PATH.join('gitaly', "#{service}.socket").to_s - end - - def praefect_socket_path - "unix:" + socket_path(:praefect) - end - - def wait(service) - sleep_time = 10 - sleep_interval = 0.1 - socket = socket_path(service) - - Integer(sleep_time / sleep_interval).times do - Socket.unix(socket) - return - rescue StandardError - sleep sleep_interval - end - - raise "could not connect to #{service} at #{socket.inspect} after #{sleep_time} seconds" - end - # Feature specs are run through Workhorse def setup_workhorse # Always rebuild the config file @@ -376,8 +296,7 @@ module TestEnv def rm_storage_dir(storage, dir) Gitlab::GitalyClient::StorageSettings.allow_disk_access do - repos_path = Gitlab.config.repositories.storages[storage].legacy_disk_path - target_repo_refs_path = File.join(repos_path, dir) + target_repo_refs_path = File.join(GitalySetup.repos_path(storage), dir) FileUtils.remove_dir(target_repo_refs_path) end rescue Errno::ENOENT @@ -385,8 +304,7 @@ module TestEnv def storage_dir_exists?(storage, dir) Gitlab::GitalyClient::StorageSettings.allow_disk_access do - repos_path = Gitlab.config.repositories.storages[storage].legacy_disk_path - File.exist?(File.join(repos_path, dir)) + File.exist?(File.join(GitalySetup.repos_path(storage), dir)) end end @@ -399,7 +317,7 @@ module TestEnv end def repos_path - @repos_path ||= Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path + @repos_path ||= GitalySetup.repos_path end def backup_path @@ -414,6 +332,18 @@ module TestEnv Gitlab.config.artifacts.storage_path end + def lfs_path + Gitlab.config.lfs.storage_path + end + + def terraform_state_path + Gitlab.config.terraform_state.storage_path + end + + def packages_path + Gitlab.config.packages.storage_path + end + # When no cached assets exist, manually hit the root path to create them # # Otherwise they'd be created by the first test, often timing out and @@ -512,7 +442,7 @@ module TestEnv end end - def component_timed_setup(component, install_dir:, version:, task:, task_args: []) + def component_timed_setup(component, install_dir:, version:, task:, fresh_install: true, task_args: []) start = Time.now ensure_component_dir_name_is_correct!(component, install_dir) @@ -522,7 +452,7 @@ module TestEnv if component_needs_update?(install_dir, version) # Cleanup the component entirely to ensure we start fresh - FileUtils.rm_rf(install_dir) + FileUtils.rm_rf(install_dir) if fresh_install if ENV['SKIP_RAILS_ENV_IN_RAKE'] # When we run `scripts/setup-test-env`, we take care of loading the necessary dependencies diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index 5865bafd382..776ea37ffdc 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -183,6 +183,10 @@ module UsageDataHelpers ) end + def stub_database_flavor_check(flavor = nil) + allow(ApplicationRecord.database).to receive(:flavor).and_return(flavor) + end + def clear_memoized_values(values) values.each { |v| described_class.clear_memoization(v) } end diff --git a/spec/support/import_export/export_file_helper.rb b/spec/support/import_export/export_file_helper.rb index f862a9bc1a4..3134e5c32a3 100644 --- a/spec/support/import_export/export_file_helper.rb +++ b/spec/support/import_export/export_file_helper.rb @@ -44,7 +44,7 @@ module ExportFileHelper create(:ci_trigger, project: project) key = create(:deploy_key) key.projects << project - create(:service, project: project) + create(:integration, project: project) create(:project_hook, project: project, token: 'token') create(:protected_branch, project: project) diff --git a/spec/support/praefect.rb b/spec/support/praefect.rb index 3218275c2aa..451b47cc83c 100644 --- a/spec/support/praefect.rb +++ b/spec/support/praefect.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require_relative 'helpers/test_env' +require_relative 'helpers/gitaly_setup' RSpec.configure do |config| config.before(:each, :praefect) do allow(Gitlab.config.repositories.storages['default']).to receive(:[]).and_call_original allow(Gitlab.config.repositories.storages['default']).to receive(:[]).with('gitaly_address') - .and_return(TestEnv.praefect_socket_path) + .and_return(GitalySetup.praefect_socket_path) end end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 085f1f13c2c..27967850389 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -142,6 +142,7 @@ RSpec.shared_context 'group navbar structure' do nav_sub_items: [ _('General'), _('Integrations'), + _('Access Tokens'), _('Projects'), _('Repository'), _('CI/CD'), diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index ad6462dc367..0dfd76de79c 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -8,7 +8,14 @@ RSpec.shared_context 'GroupPolicy context' do let_it_be(:owner) { create(:user) } let_it_be(:admin) { create(:admin) } let_it_be(:non_group_member) { create(:user) } - let_it_be(:group, refind: true) { create(:group, :private, :owner_subgroup_creation_only) } + let_it_be(:group, refind: true) { create(:group, :private, :owner_subgroup_creation_only, :crm_enabled) } + + let(:public_permissions) do + %i[ + read_group read_counts + read_label read_issue_board_list read_milestone read_issue_board + ] + end let(:guest_permissions) do %i[ @@ -18,8 +25,6 @@ RSpec.shared_context 'GroupPolicy context' do ] end - let(:read_group_permissions) { %i[read_label read_issue_board_list read_milestone read_issue_board] } - let(:reporter_permissions) do %i[ admin_label @@ -28,6 +33,8 @@ RSpec.shared_context 'GroupPolicy context' do read_metrics_dashboard_annotation read_prometheus read_package_settings + read_crm_contact + read_crm_organization ] end @@ -48,22 +55,24 @@ RSpec.shared_context 'GroupPolicy context' do destroy_package create_projects read_cluster create_cluster update_cluster admin_cluster add_cluster - admin_group_runners ] end let(:owner_permissions) do - [ - :owner_access, - :admin_group, - :admin_namespace, - :admin_group_member, - :change_visibility_level, - :set_note_created_at, - :create_subgroup, - :read_statistics, - :update_default_branch_protection - ].compact + %i[ + owner_access + admin_group + admin_namespace + admin_group_member + change_visibility_level + set_note_created_at + create_subgroup + read_statistics + update_default_branch_protection + read_group_runners + admin_group_runners + register_group_runners + ] end let(:admin_permissions) { %i[read_confidential_issues] } diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 8a90f887381..c39252cef13 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -50,7 +50,7 @@ RSpec.shared_context 'ProjectPolicy context' do resolve_note update_build update_commit_status update_container_image update_deployment update_environment update_merge_request update_metrics_dashboard_annotation update_pipeline update_release destroy_release - read_resource_group update_resource_group + read_resource_group update_resource_group update_escalation_status ] end diff --git a/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb index 8affe4ac8f5..08d0be8c7ac 100644 --- a/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb +++ b/spec/support/shared_examples/controllers/create_notes_rate_limit_shared_examples.rb @@ -3,44 +3,19 @@ # Requires a context containing: # - user # - params -# - request_full_path -RSpec.shared_examples 'request exceeding rate limit' do - context 'with rate limiter', :freeze_time, :clean_gitlab_redis_rate_limiting do - before do - stub_application_setting(notes_create_limit: 2) - 2.times { post :create, params: params } - end +RSpec.shared_examples 'create notes request exceeding rate limit' do + include_examples 'rate limited endpoint', rate_limit_key: :notes_create - it 'prevents from creating more notes' do - expect { post :create, params: params } - .to change { Note.count }.by(0) + it 'allows user in allow-list to create notes, even if the case is different', :freeze_time, :clean_gitlab_redis_rate_limiting do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:notes_create).and_return(1) - expect(response).to have_gitlab_http_status(:too_many_requests) - expect(response.body).to eq(_('This endpoint has been requested too many times. Try again later.')) - end + current_user.update_attribute(:username, current_user.username.titleize) + stub_application_setting(notes_create_limit_allowlist: [current_user.username.downcase]) - it 'logs the event in auth.log' do - attributes = { - message: 'Application_Rate_Limiter_Request', - env: :notes_create_request_limit, - remote_ip: '0.0.0.0', - request_method: 'POST', - path: request_full_path, - user_id: user.id, - username: user.username - } + request + request - expect(Gitlab::AuthLogger).to receive(:error).with(attributes).once - post :create, params: params - end - - it 'allows user in allow-list to create notes, even if the case is different' do - user.update_attribute(:username, user.username.titleize) - stub_application_setting(notes_create_limit_allowlist: ["#{user.username.downcase}"]) - - post :create, params: params - expect(response).to have_gitlab_http_status(:found) - end + expect(response).to have_gitlab_http_status(:found) end end diff --git a/spec/support/shared_examples/controllers/rate_limited_endpoint_shared_examples.rb b/spec/support/shared_examples/controllers/rate_limited_endpoint_shared_examples.rb new file mode 100644 index 00000000000..bb2a4159071 --- /dev/null +++ b/spec/support/shared_examples/controllers/rate_limited_endpoint_shared_examples.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# +# Requires a context containing: +# - request (use method definition to avoid memoizing!) +# - current_user +# - error_message # optional + +RSpec.shared_examples 'rate limited endpoint' do |rate_limit_key:| + context 'when rate limiter enabled', :freeze_time, :clean_gitlab_redis_rate_limiting do + let(:expected_logger_attributes) do + { + message: 'Application_Rate_Limiter_Request', + env: :"#{rate_limit_key}_request_limit", + remote_ip: kind_of(String), + request_method: kind_of(String), + path: kind_of(String), + user_id: current_user.id, + username: current_user.username + } + end + + let(:error_message) { _('This endpoint has been requested too many times. Try again later.') } + + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(rate_limit_key).and_return(1) + end + + it 'logs request and declines it when endpoint called more than the threshold' do |example| + expect(Gitlab::AuthLogger).to receive(:error).with(expected_logger_attributes).once + + request + request + + expect(response).to have_gitlab_http_status(:too_many_requests) + + if example.metadata[:type] == :controller + expect(response.body).to eq(error_message) + else # it is API spec + expect(response.body).to eq({ message: { error: error_message } }.to_json) + end + end + end + + context 'when rate limiter is disabled' do + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(rate_limit_key).and_return(0) + end + + it 'does not log request and does not block the request' do + expect(Gitlab::AuthLogger).not_to receive(:error) + + request + + expect(response).not_to have_gitlab_http_status(:too_many_requests) + end + end +end diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb new file mode 100644 index 00000000000..ae246a87bb6 --- /dev/null +++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'resource access tokens missing access rights' do + it 'does not show access token page' do + visit resource_settings_access_tokens_path + + expect(page).to have_content("Page Not Found") + end +end + +RSpec.shared_examples 'resource access tokens creation' do |resource_type| + def active_resource_access_tokens + find('.table.active-tokens') + end + + def created_resource_access_token + find('#created-personal-access-token').value + end + + it 'allows creation of an access token', :aggregate_failures do + name = 'My access token' + + visit resource_settings_access_tokens_path + fill_in 'Token name', with: name + + # Set date to 1st of next month + find_field('Expiration date').click + find('.pika-next').click + click_on '1' + + # Scopes + check 'api' + check 'read_api' + + click_on "Create #{resource_type} access token" + + expect(active_resource_access_tokens).to have_text(name) + expect(active_resource_access_tokens).to have_text('in') + expect(active_resource_access_tokens).to have_text('api') + expect(active_resource_access_tokens).to have_text('read_api') + expect(active_resource_access_tokens).to have_text('Maintainer') + expect(created_resource_access_token).not_to be_empty + end +end + +RSpec.shared_examples 'resource access tokens creation disallowed' do |error_message| + before do + group.namespace_settings.update_column(:resource_access_token_creation_allowed, false) + end + + it 'does not show access token creation form' do + visit resource_settings_access_tokens_path + + expect(page).not_to have_selector('#new_resource_access_token') + end + + it 'shows access token creation disabled text' do + visit resource_settings_access_tokens_path + + expect(page).to have_text(error_message) + end + + context 'group settings link' do + context 'when user is not a group owner' do + before do + group.add_developer(user) + end + + it 'does not show group settings link' do + visit resource_settings_access_tokens_path + + expect(page).not_to have_link('group settings', href: edit_group_path(group)) + end + end + + context 'with nested groups' do + let(:parent_group) { create(:group) } + let(:group) { create(:group, parent: parent_group) } + + context 'when user is not a top level group owner' do + before do + group.add_owner(user) + end + + it 'does not show group settings link' do + visit resource_settings_access_tokens_path + + expect(page).not_to have_link('group settings', href: edit_group_path(group)) + end + end + end + + context 'when user is a group owner' do + before do + group.add_owner(user) + end + + it 'shows group settings link' do + visit resource_settings_access_tokens_path + + expect(page).to have_link('group settings', href: edit_group_path(group)) + end + end + end +end + +RSpec.shared_examples 'active resource access tokens' do + def active_resource_access_tokens + find('.table.active-tokens') + end + + it 'shows active access tokens' do + visit resource_settings_access_tokens_path + + expect(active_resource_access_tokens).to have_text(resource_access_token.name) + end + + context 'when User#time_display_relative is false' do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit resource_settings_access_tokens_path + + expect(active_resource_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d')) + end + end +end + +RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_text| + def no_resource_access_tokens_message + find('.settings-message') + end + + it 'allows revocation of an active token' do + visit resource_settings_access_tokens_path + accept_confirm { click_on 'Revoke' } + + expect(page).to have_selector('.settings-message') + expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) + end + + it 'removes expired tokens from active section' do + resource_access_token.update!(expires_at: 5.days.ago) + visit resource_settings_access_tokens_path + + expect(page).to have_selector('.settings-message') + expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) + end + + context 'when resource access token creation is not allowed' do + before do + group.namespace_settings.update_column(:resource_access_token_creation_allowed, false) + end + + it 'allows revocation of an active token' do + visit resource_settings_access_tokens_path + accept_confirm { click_on 'Revoke' } + + expect(page).to have_selector('.settings-message') + expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) + end + end +end diff --git a/spec/support/shared_examples/features/packages_shared_examples.rb b/spec/support/shared_examples/features/packages_shared_examples.rb index d14b4638ca5..ded30f32314 100644 --- a/spec/support/shared_examples/features/packages_shared_examples.rb +++ b/spec/support/shared_examples/features/packages_shared_examples.rb @@ -19,14 +19,12 @@ RSpec.shared_examples 'packages list' do |check_project_name: false| end RSpec.shared_examples 'package details link' do |property| - let(:package) { packages.first } - it 'navigates to the correct url' do page.within(packages_table_selector) do click_link package.name end - expect(page).to have_current_path(project_package_path(package.project, package)) + expect(page).to have_current_path(package_details_path) expect(page).to have_css('.packages-app h2[data-testid="title"]', text: package.name) diff --git a/spec/support/shared_examples/features/sidebar/sidebar_labels_shared_examples.rb b/spec/support/shared_examples/features/sidebar/sidebar_labels_shared_examples.rb new file mode 100644 index 00000000000..a9dac7a391f --- /dev/null +++ b/spec/support/shared_examples/features/sidebar/sidebar_labels_shared_examples.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'labels sidebar widget' do + context 'editing labels' do + let_it_be(:development) { create(:group_label, group: group, name: 'Development') } + let_it_be(:stretch) { create(:label, project: project, name: 'Stretch') } + let_it_be(:xss_label) { create(:label, project: project, title: '<script>alert("xss");</script>') } + + let(:labels_widget) { find('[data-testid="sidebar-labels"]') } + + before do + page.within(labels_widget) do + click_on 'Edit' + end + + wait_for_all_requests + end + + it 'shows labels list in the dropdown' do + expect(labels_widget.find('.gl-new-dropdown-contents')).to have_selector('li.gl-new-dropdown-item', count: 4) + end + + it 'adds a label' do + within(labels_widget) do + adds_label(stretch) + + page.within('[data-testid="value-wrapper"]') do + expect(page).to have_content(stretch.name) + end + end + end + + it 'removes a label' do + within(labels_widget) do + adds_label(stretch) + page.within('[data-testid="value-wrapper"]') do + expect(page).to have_content(stretch.name) + end + + click_on 'Remove label' + + wait_for_requests + + page.within('[data-testid="value-wrapper"]') do + expect(page).not_to have_content(stretch.name) + end + end + end + + it 'adds first label by pressing enter when search' do + within(labels_widget) do + page.within('[data-testid="value-wrapper"]') do + expect(page).not_to have_content(development.name) + end + + fill_in 'Search', with: 'Devel' + sleep 1 + expect(page.all(:css, '[data-testid="dropdown-content"] .gl-new-dropdown-item').length).to eq(1) + + find_field('Search').native.send_keys(:enter) + click_button 'Close' + wait_for_requests + + page.within('[data-testid="value-wrapper"]') do + expect(page).to have_content(development.name) + end + end + end + + it 'escapes XSS when viewing issuable labels' do + page.within(labels_widget) do + expect(page).to have_content '<script>alert("xss");</script>' + end + end + + it 'shows option to create a label' do + page.within(labels_widget) do + expect(page).to have_content 'Create' + end + end + + context 'creating a label', :js do + before do + page.within(labels_widget) do + page.find('[data-testid="create-label-button"]').click + end + end + + it 'shows dropdown switches to "create label" section' do + page.within(labels_widget) do + expect(page.find('[data-testid="dropdown-header"]')).to have_content 'Create' + end + end + + it 'creates new label' do + page.within(labels_widget) do + fill_in 'Name new label', with: 'wontfix' + page.find('.suggest-colors a', match: :first).click + page.find('button', text: 'Create').click + wait_for_requests + + expect(page).to have_content 'wontfix' + end + end + + it 'shows error message if label title is taken' do + page.within(labels_widget) do + fill_in 'Name new label', with: development.title + page.find('.suggest-colors a', match: :first).click + page.find('button', text: 'Create').click + wait_for_requests + + page.within('.dropdown-input') do + expect(page.find('.gl-alert')).to have_content 'Title' + end + end + end + end + end + + def adds_label(label) + click_button label.name + click_button 'Close' + + wait_for_requests + end +end diff --git a/spec/support/shared_examples/features/sidebar_shared_examples.rb b/spec/support/shared_examples/features/sidebar_shared_examples.rb index 615f568420e..11d216ff4b6 100644 --- a/spec/support/shared_examples/features/sidebar_shared_examples.rb +++ b/spec/support/shared_examples/features/sidebar_shared_examples.rb @@ -50,6 +50,10 @@ RSpec.shared_examples 'issue boards sidebar' do it_behaves_like 'date sidebar widget' end + context 'editing issue labels', :js do + it_behaves_like 'labels sidebar widget' + end + context 'in notifications subscription' do it 'displays notifications toggle', :aggregate_failures do page.within('[data-testid="sidebar-notifications"]') do diff --git a/spec/support/shared_examples/finders/snippet_visibility_shared_examples.rb b/spec/support/shared_examples/finders/snippet_visibility_shared_examples.rb index a2c34cdd4a1..601a53ed913 100644 --- a/spec/support/shared_examples/finders/snippet_visibility_shared_examples.rb +++ b/spec/support/shared_examples/finders/snippet_visibility_shared_examples.rb @@ -233,7 +233,7 @@ RSpec.shared_examples 'snippet visibility' do project.update!(visibility_level: Gitlab::VisibilityLevel.level_value(project_visibility.to_s), snippets_access_level: feature_visibility) if user_type == :external - member = project.project_member(external) + member = project.member(external) if project.private? project.add_developer(external) unless member diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb index 51d52cbb901..dc590e23ace 100644 --- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb @@ -8,7 +8,7 @@ # There must be a method or let called `mutation` defined that executes # the mutation. RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []| - let(:match_errors) { eq(errors) } + let(:match_errors) { match_array(errors) } it do post_graphql_mutation(mutation, current_user: current_user) diff --git a/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb index 34c58f524cd..05fee45427a 100644 --- a/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb @@ -1,12 +1,34 @@ # frozen_string_literal: true RSpec.shared_examples 'permission level for issue mutation is correctly verified' do |raises_for_all_errors = false| - before do - issue.assignees = [] - issue.author = user + let_it_be(:other_user_author) { create(:user) } + + def issue_attributes(issue) + issue.attributes.except( + # Description and title can be updated by authors and assignees of the issues + 'description', + 'title', + # Those fields are calculated or expected to be modified during the mutations + 'author_id', + 'updated_at', + 'updated_by_id', + 'last_edited_at', + 'last_edited_by_id', + 'lock_version', + # There were spec failures due to nano-second comparisons + # this property isn't changed by any mutation so we don't have to verify it + 'created_at' + ) end - shared_examples_for 'when the user does not have access to the resource' do |raise_for_assigned| + let(:expected) { issue_attributes(issue) } + + shared_examples_for 'when the user does not have access to the resource' do |raise_for_assigned_and_author| + before do + issue.assignees = [] + issue.update!(author: other_user_author) + end + it 'raises an error' do expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) end @@ -17,21 +39,25 @@ RSpec.shared_examples 'permission level for issue mutation is correctly verified end it 'does not modify issue' do - if raises_for_all_errors || raise_for_assigned + if raises_for_all_errors || raise_for_assigned_and_author expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) else - expect(subject[:issue]).to eq issue + expect(issue_attributes(subject[:issue])).to eq expected end end end context 'even if author of the issue' do before do - issue.author = user + issue.update!(author: user) end - it 'raises an error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + it 'does not modify issue' do + if raises_for_all_errors || raise_for_assigned_and_author + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + else + expect(issue_attributes(subject[:issue])).to eq expected + end end end end diff --git a/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb index 1ddbad1cea7..b0ac742079a 100644 --- a/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb @@ -1,13 +1,39 @@ # frozen_string_literal: true RSpec.shared_examples 'permission level for merge request mutation is correctly verified' do - before do - merge_request.assignees = [] - merge_request.reviewers = [] - merge_request.author = nil + let(:other_user_author) { create(:user) } + + def mr_attributes(mr) + mr.attributes.except( + # Authors and assignees can edit title, description, target branch and draft status + 'title', + 'description', + 'target_branch', + 'draft', + # Those fields are calculated or expected to be modified during the mutations + 'author_id', + 'latest_merge_request_diff_id', + 'last_edited_at', + 'last_edited_by_id', + 'lock_version', + 'updated_at', + 'updated_by_id', + 'merge_status', + # There were spec failures due to nano-second comparisons + # this property isn't changed by any mutation so we don't have to verify it + 'created_at' + ) end - shared_examples_for 'when the user does not have access to the resource' do |raise_for_assigned| + let(:expected) { mr_attributes(merge_request) } + + shared_examples_for 'when the user does not have access to the resource' do |raise_for_assigned_and_author| + before do + merge_request.assignees = [] + merge_request.reviewers = [] + merge_request.update!(author: other_user_author) + end + it 'raises an error' do expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) end @@ -18,12 +44,12 @@ RSpec.shared_examples 'permission level for merge request mutation is correctly end it 'does not modify merge request' do - if raise_for_assigned + if raise_for_assigned_and_author expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) else # In some cases we simply do nothing instead of raising # https://gitlab.com/gitlab-org/gitlab/-/issues/196241 - expect(subject[:merge_request]).to eq merge_request + expect(mr_attributes(subject[:merge_request])).to eq expected end end end @@ -40,11 +66,17 @@ RSpec.shared_examples 'permission level for merge request mutation is correctly context 'even if author of the merge request' do before do - merge_request.author = user + merge_request.update!(author: user) end it 'raises an error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + if raise_for_assigned_and_author + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + else + # In some cases we simply do nothing instead of raising + # https://gitlab.com/gitlab-org/gitlab/-/issues/196241 + expect(mr_attributes(subject[:merge_request])).to eq expected + end end end end diff --git a/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb index 7888ade56eb..213f084be17 100644 --- a/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb @@ -22,19 +22,19 @@ RSpec.shared_examples 'marks background migration job records' do end end -RSpec.shared_examples 'finalized background migration' do +RSpec.shared_examples 'finalized background migration' do |worker_class| it 'processed the scheduled sidekiq queue' do queued = Sidekiq::ScheduledSet .new .select do |scheduled| - scheduled.klass == 'BackgroundMigrationWorker' && + scheduled.klass == worker_class.name && scheduled.args.first == job_class_name end expect(queued.size).to eq(0) end it 'processed the async sidekiq queue' do - queued = Sidekiq::Queue.new('BackgroundMigrationWorker') + queued = Sidekiq::Queue.new(worker_class.name) .select { |scheduled| scheduled.klass == job_class_name } expect(queued.size).to eq(0) end @@ -42,8 +42,8 @@ RSpec.shared_examples 'finalized background migration' do include_examples 'removed tracked jobs', 'pending' end -RSpec.shared_examples 'finalized tracked background migration' do - include_examples 'finalized background migration' +RSpec.shared_examples 'finalized tracked background migration' do |worker_class| + include_examples 'finalized background migration', worker_class include_examples 'removed tracked jobs', 'succeeded' end diff --git a/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb deleted file mode 100644 index 046c70bf779..00000000000 --- a/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'multi store feature flags' do |use_primary_and_secondary_stores, use_primary_store_as_default| - context "with feature flag :#{use_primary_and_secondary_stores} is enabled" do - before do - stub_feature_flags(use_primary_and_secondary_stores => true) - end - - it 'multi store is enabled' do - expect(subject.use_primary_and_secondary_stores?).to be true - end - end - - context "with feature flag :#{use_primary_and_secondary_stores} is disabled" do - before do - stub_feature_flags(use_primary_and_secondary_stores => false) - end - - it 'multi store is disabled' do - expect(subject.use_primary_and_secondary_stores?).to be false - end - end - - context "with feature flag :#{use_primary_store_as_default} is enabled" do - before do - stub_feature_flags(use_primary_store_as_default => true) - end - - it 'primary store is enabled' do - expect(subject.use_primary_store_as_default?).to be true - end - end - - context "with feature flag :#{use_primary_store_as_default} is disabled" do - before do - stub_feature_flags(use_primary_store_as_default => false) - end - - it 'primary store is disabled' do - expect(subject.use_primary_store_as_default?).to be false - end - end -end diff --git a/spec/support/shared_examples/lib/gitlab/unique_ip_check_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/unique_ip_check_shared_examples.rb index e42a927b5ba..c735b98aa23 100644 --- a/spec/support/shared_examples/lib/gitlab/unique_ip_check_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/unique_ip_check_shared_examples.rb @@ -7,13 +7,13 @@ RSpec.shared_examples 'user login operation with unique ip limit' do end it 'allows user authenticating from the same ip' do - expect { operation_from_ip('ip') }.not_to raise_error - expect { operation_from_ip('ip') }.not_to raise_error + expect { operation_from_ip('111.221.4.3') }.not_to raise_error + expect { operation_from_ip('111.221.4.3') }.not_to raise_error end it 'blocks user authenticating from two distinct ips' do - expect { operation_from_ip('ip') }.not_to raise_error - expect { operation_from_ip('ip2') }.to raise_error(Gitlab::Auth::TooManyIps) + expect { operation_from_ip('111.221.4.3') }.not_to raise_error + expect { operation_from_ip('1.2.2.3') }.to raise_error(Gitlab::Auth::TooManyIps) end end end @@ -25,13 +25,13 @@ RSpec.shared_examples 'user login request with unique ip limit' do |success_stat end it 'allows user authenticating from the same ip' do - expect(request_from_ip('ip')).to have_gitlab_http_status(success_status) - expect(request_from_ip('ip')).to have_gitlab_http_status(success_status) + expect(request_from_ip('111.221.4.3')).to have_gitlab_http_status(success_status) + expect(request_from_ip('111.221.4.3')).to have_gitlab_http_status(success_status) end it 'blocks user authenticating from two distinct ips' do - expect(request_from_ip('ip')).to have_gitlab_http_status(success_status) - expect(request_from_ip('ip2')).to have_gitlab_http_status(:forbidden) + expect(request_from_ip('111.221.4.3')).to have_gitlab_http_status(success_status) + expect(request_from_ip('1.2.2.3')).to have_gitlab_http_status(:forbidden) end end end diff --git a/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb b/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb index 8f3a93de509..42eec74e64f 100644 --- a/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb +++ b/spec/support/shared_examples/loose_foreign_keys/have_loose_foreign_key.rb @@ -55,10 +55,16 @@ RSpec.shared_examples 'cleanup by a loose foreign key' do end def find_model - model.class.find_by(id: model.id) + query = model.class + # handle composite primary keys + connection = model.class.connection + connection.primary_keys(model.class.table_name).each do |primary_key| + query = query.where(primary_key => model.public_send(primary_key)) + end + query.first end - it 'deletes the model' do + it 'cleans up (delete or nullify) the model' do parent.delete expect(find_model).to be_present diff --git a/spec/support/shared_examples/metrics/sampler_shared_examples.rb b/spec/support/shared_examples/metrics/sampler_shared_examples.rb index ebf199c3a8d..cec540cd120 100644 --- a/spec/support/shared_examples/metrics/sampler_shared_examples.rb +++ b/spec/support/shared_examples/metrics/sampler_shared_examples.rb @@ -2,26 +2,98 @@ RSpec.shared_examples 'metrics sampler' do |env_prefix| context 'when sampling interval is passed explicitly' do - subject { described_class.new(42) } + subject(:sampler) { described_class.new(interval: 42, logger: double) } - specify { expect(subject.interval).to eq(42) } + specify { expect(sampler.interval).to eq(42) } end context 'when sampling interval is passed through the environment' do - subject { described_class.new } + subject(:sampler) { described_class.new(logger: double) } before do stub_env("#{env_prefix}_INTERVAL_SECONDS", '42') end - specify { expect(subject.interval).to eq(42) } + specify { expect(sampler.interval).to eq(42) } end context 'when no sampling interval is passed anywhere' do - subject { described_class.new } + subject(:sampler) { described_class.new(logger: double) } it 'uses the hardcoded default' do - expect(subject.interval).to eq(described_class::DEFAULT_SAMPLING_INTERVAL_SECONDS) + expect(sampler.interval).to eq(described_class::DEFAULT_SAMPLING_INTERVAL_SECONDS) + end + end + + describe '#start' do + include WaitHelpers + + subject(:sampler) { described_class.new(interval: 0.1) } + + it 'calls the sample method on the sampler thread' do + sampling_threads = [] + expect(sampler).to receive(:sample).at_least(:once) { sampling_threads << Thread.current } + + sampler.start + + wait_for('sampler has sampled', max_wait_time: 3) { sampling_threads.any? } + expect(sampling_threads.first.name).to eq(sampler.thread_name) + + sampler.stop + end + + context 'with warmup set to true' do + subject(:sampler) { described_class.new(interval: 0.1, warmup: true) } + + it 'calls the sample method first on the caller thread' do + sampling_threads = [] + current_thread = Thread.current + # Instead of sampling, we're keeping track of which thread the sampling happened on. + # We want the first sample to be on the spec thread, which would mean a blocking sample + # before the actual sampler thread starts. + expect(sampler).to receive(:sample).at_least(:once) { sampling_threads << Thread.current } + + sampler.start + + wait_for('sampler has sampled', max_wait_time: 3) { sampling_threads.size == 2 } + + expect(sampling_threads.first).to be(current_thread) + expect(sampling_threads.last.name).to eq(sampler.thread_name) + + sampler.stop + end + end + end + + describe '#safe_sample' do + let(:logger) { Logger.new(File::NULL) } + + subject(:sampler) { described_class.new(logger: logger) } + + it 'calls #sample once' do + expect(sampler).to receive(:sample) + + sampler.safe_sample + end + + context 'when sampling fails with error' do + before do + expect(sampler).to receive(:sample).and_raise "something failed" + end + + it 'recovers from errors' do + expect { sampler.safe_sample }.not_to raise_error + end + + context 'with logger' do + let(:logger) { double('logger') } + + it 'logs errors' do + expect(logger).to receive(:warn).with(an_instance_of(String)) + + expect { sampler.safe_sample }.not_to raise_error + end + end end end end diff --git a/spec/support/shared_examples/models/application_setting_shared_examples.rb b/spec/support/shared_examples/models/application_setting_shared_examples.rb index 60a02d85a1e..38f5c7be393 100644 --- a/spec/support/shared_examples/models/application_setting_shared_examples.rb +++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb @@ -94,7 +94,7 @@ RSpec.shared_examples 'application settings examples' do '1:2:3:4:5::7:8', '[1:2:3:4:5::7:8]', '[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443', - 'www.example2.com:8080', + 'www.example.org:8080', 'example.com:8080' ] @@ -114,7 +114,7 @@ RSpec.shared_examples 'application settings examples' do an_object_having_attributes(domain: 'example.com'), an_object_having_attributes(domain: 'subdomain.example.com'), an_object_having_attributes(domain: 'www.example.com'), - an_object_having_attributes(domain: 'www.example2.com', port: 8080), + an_object_having_attributes(domain: 'www.example.org', port: 8080), an_object_having_attributes(domain: 'example.com', port: 8080) ] diff --git a/spec/support/shared_examples/models/concerns/incident_management/escalatable_shared_examples.rb b/spec/support/shared_examples/models/concerns/incident_management/escalatable_shared_examples.rb index 7b33a95bfa1..8ee76efc896 100644 --- a/spec/support/shared_examples/models/concerns/incident_management/escalatable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/incident_management/escalatable_shared_examples.rb @@ -95,6 +95,12 @@ RSpec.shared_examples 'a model including Escalatable' do it { is_expected.to eq([ignored_escalatable, resolved_escalatable, acknowledged_escalatable, triggered_escalatable]) } end end + + describe '.open' do + subject { all_escalatables.open } + + it { is_expected.to contain_exactly(acknowledged_escalatable, triggered_escalatable) } + end end describe '.status_value' do @@ -133,6 +139,24 @@ RSpec.shared_examples 'a model including Escalatable' do end end + describe '.open_status?' do + using RSpec::Parameterized::TableSyntax + + where(:status, :is_open_status) do + :triggered | true + :acknowledged | true + :resolved | false + :ignored | false + nil | false + end + + with_them do + it 'returns true when the status is open status' do + expect(described_class.open_status?(status)).to eq(is_open_status) + end + end + end + describe '#trigger' do subject { escalatable.trigger } @@ -237,6 +261,15 @@ RSpec.shared_examples 'a model including Escalatable' do end end + describe '#open?' do + it 'returns true when the status is open status' do + expect(triggered_escalatable.open?).to be true + expect(acknowledged_escalatable.open?).to be true + expect(resolved_escalatable.open?).to be false + expect(ignored_escalatable.open?).to be false + end + end + private def factory_from_class(klass) diff --git a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb index ad15f82be5e..2a976fb7421 100644 --- a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| +RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name| include StubRequests - let(:chat_service) { described_class.new } + let(:chat_integration) { described_class.new } let(:webhook_url) { 'https://example.gitlab.com' } def execute_with_options(options) @@ -17,7 +17,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| end describe 'Validations' do - context 'when service is active' do + context 'when integration is active' do before do subject.active = true end @@ -26,7 +26,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| it_behaves_like 'issue tracker integration URL attribute', :webhook end - context 'when service is inactive' do + context 'when integration is inactive' do before do subject.active = false end @@ -35,9 +35,9 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| end end - shared_examples "triggered #{service_name} service" do |event_type: nil, branches_to_be_notified: nil| + shared_examples "triggered #{integration_name} integration" do |event_type: nil, branches_to_be_notified: nil| before do - chat_service.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified + chat_integration.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified end let!(:stubbed_resolved_hostname) do @@ -45,14 +45,14 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| end it "notifies about #{event_type} events" do - chat_service.execute(data) + chat_integration.execute(data) expect(WebMock).to have_requested(:post, stubbed_resolved_hostname) end end - shared_examples "untriggered #{service_name} service" do |event_type: nil, branches_to_be_notified: nil| + shared_examples "untriggered #{integration_name} integration" do |event_type: nil, branches_to_be_notified: nil| before do - chat_service.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified + chat_integration.branches_to_be_notified = branches_to_be_notified if branches_to_be_notified end let!(:stubbed_resolved_hostname) do @@ -60,7 +60,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| end it "notifies about #{event_type} events" do - chat_service.execute(data) + chat_integration.execute(data) expect(WebMock).not_to have_requested(:post, stubbed_resolved_hostname) end end @@ -69,50 +69,50 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let_it_be(:project) { create(:project, :repository, :wiki_repo) } let_it_be(:user) { create(:user) } - let(:chat_service) { described_class.new( { project: project, webhook: webhook_url, branches_to_be_notified: 'all' }.merge(chat_service_params)) } - let(:chat_service_params) { {} } + let(:chat_integration) { described_class.new( { project: project, webhook: webhook_url, branches_to_be_notified: 'all' }.merge(chat_integration_params)) } + let(:chat_integration_params) { {} } let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } let!(:stubbed_resolved_hostname) do stub_full_request(webhook_url, method: :post).request_pattern.uri_pattern.to_s end - subject(:execute_service) { chat_service.execute(data) } + subject(:execute_integration) { chat_integration.execute(data) } - shared_examples 'calls the service API with the event message' do |event_message| + shared_examples 'calls the integration API with the event message' do |event_message| specify do expect_next_instance_of(::Slack::Messenger) do |messenger| expect(messenger).to receive(:ping).with(event_message, anything).and_call_original end - execute_service + execute_integration expect(WebMock).to have_requested(:post, stubbed_resolved_hostname).once end end context 'with username for slack configured' do - let(:chat_service_params) { { username: 'slack_username' } } + let(:chat_integration_params) { { username: 'slack_username' } } it 'uses the username as an option' do expect(::Slack::Messenger).to execute_with_options(username: 'slack_username') - execute_service + execute_integration end end context 'push events' do let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } - it_behaves_like 'calls the service API with the event message', /pushed to branch/ + it_behaves_like 'calls the integration API with the event message', /pushed to branch/ context 'with event channel' do - let(:chat_service_params) { { push_channel: 'random' } } + let(:chat_integration_params) { { push_channel: 'random' } } it 'uses the right channel for push event' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end end end @@ -123,7 +123,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:ref) { 'refs/tags/v1.1.0' } let(:data) { Git::TagHooksService.new(project, user, change: { oldrev: oldrev, newrev: newrev, ref: ref }).send(:push_data) } - it_behaves_like 'calls the service API with the event message', /pushed new tag/ + it_behaves_like 'calls the integration API with the event message', /pushed new tag/ end context 'issue events' do @@ -131,15 +131,15 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { issue.to_hook_data(user) } - it_behaves_like 'calls the service API with the event message', /Issue (.*?) opened by/ + it_behaves_like 'calls the integration API with the event message', /Issue (.*?) opened by/ context 'whith event channel' do - let(:chat_service_params) { { issue_channel: 'random' } } + let(:chat_integration_params) { { issue_channel: 'random' } } it 'uses the right channel for issue event' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end context 'for confidential issues' do @@ -150,16 +150,16 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| it 'falls back to issue channel' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end context 'and confidential_issue_channel is defined' do - let(:chat_service_params) { { issue_channel: 'random', confidential_issue_channel: 'confidential' } } + let(:chat_integration_params) { { issue_channel: 'random', confidential_issue_channel: 'confidential' } } it 'uses the confidential issue channel when it is defined' do expect(::Slack::Messenger).to execute_with_options(channel: ['confidential']) - execute_service + execute_integration end end end @@ -171,15 +171,15 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { merge_request.to_hook_data(user) } - it_behaves_like 'calls the service API with the event message', /opened merge request/ + it_behaves_like 'calls the integration API with the event message', /opened merge request/ context 'with event channel' do - let(:chat_service_params) { { merge_request_channel: 'random' } } + let(:chat_integration_params) { { merge_request_channel: 'random' } } it 'uses the right channel for merge request event' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end end end @@ -189,15 +189,15 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') } - it_behaves_like 'calls the service API with the event message', %r{ created (.*?)wikis/(.*?)|wiki page> in} + it_behaves_like 'calls the integration API with the event message', %r{ created (.*?)wikis/(.*?)|wiki page> in} context 'with event channel' do - let(:chat_service_params) { { wiki_page_channel: 'random' } } + let(:chat_integration_params) { { wiki_page_channel: 'random' } } it 'uses the right channel for wiki event' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end end end @@ -207,7 +207,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Deployment.build(deployment, Time.current) } - it_behaves_like 'calls the service API with the event message', /Deploy to (.*?) created/ + it_behaves_like 'calls the integration API with the event message', /Deploy to (.*?) created/ end context 'note event' do @@ -215,15 +215,15 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Note.build(issue_note, user) } - it_behaves_like 'calls the service API with the event message', /commented on issue/ + it_behaves_like 'calls the integration API with the event message', /commented on issue/ context 'with event channel' do - let(:chat_service_params) { { note_channel: 'random' } } + let(:chat_integration_params) { { note_channel: 'random' } } it 'uses the right channel' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end context 'for confidential notes' do @@ -234,16 +234,16 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| it 'falls back to note channel' do expect(::Slack::Messenger).to execute_with_options(channel: ['random']) - execute_service + execute_integration end context 'and confidential_note_channel is defined' do - let(:chat_service_params) { { note_channel: 'random', confidential_note_channel: 'confidential' } } + let(:chat_integration_params) { { note_channel: 'random', confidential_note_channel: 'confidential' } } it 'uses confidential channel' do expect(::Slack::Messenger).to execute_with_options(channel: ['confidential']) - execute_service + execute_integration end end end @@ -256,7 +256,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:project) { create(:project, :repository, creator: user) } before do - allow(chat_service).to receive_messages( + allow(chat_integration).to receive_messages( project: project, service_hook: true, webhook: webhook_url @@ -283,23 +283,23 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| ) end - it_behaves_like "triggered #{service_name} service", event_type: "push" + it_behaves_like "triggered #{integration_name} integration", event_type: "push" end context 'notification enabled only for default branch' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "default" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "untriggered #{service_name} service", event_type: "push", branches_to_be_notified: "protected" + it_behaves_like "untriggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "default_and_protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "all" end end @@ -325,23 +325,23 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| ) end - it_behaves_like "triggered #{service_name} service", event_type: "push" + it_behaves_like "triggered #{integration_name} integration", event_type: "push" end context 'notification enabled only for default branch' do - it_behaves_like "untriggered #{service_name} service", event_type: "push", branches_to_be_notified: "default" + it_behaves_like "untriggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "default_and_protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "all" end end @@ -367,23 +367,23 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| ) end - it_behaves_like "triggered #{service_name} service", event_type: "push" + it_behaves_like "triggered #{integration_name} integration", event_type: "push" end context 'notification enabled only for default branch' do - it_behaves_like "untriggered #{service_name} service", event_type: "push", branches_to_be_notified: "default" + it_behaves_like "untriggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "default_and_protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "all" end end @@ -405,23 +405,23 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| ) end - it_behaves_like "triggered #{service_name} service", event_type: "push" + it_behaves_like "triggered #{integration_name} integration", event_type: "push" end context 'notification enabled only for default branch' do - it_behaves_like "untriggered #{service_name} service", event_type: "push", branches_to_be_notified: "default" + it_behaves_like "untriggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "untriggered #{service_name} service", event_type: "push", branches_to_be_notified: "protected" + it_behaves_like "untriggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "untriggered #{service_name} service", event_type: "push", branches_to_be_notified: "default_and_protected" + it_behaves_like "untriggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "push", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "push", branches_to_be_notified: "all" end end end @@ -431,7 +431,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:project) { create(:project, :repository, creator: user) } before do - allow(chat_service).to receive_messages( + allow(chat_integration).to receive_messages( project: project, service_hook: true, webhook: webhook_url @@ -452,7 +452,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| Gitlab::DataBuilder::Note.build(commit_note, user) end - it_behaves_like "triggered #{service_name} service", event_type: "commit comment" + it_behaves_like "triggered #{integration_name} integration", event_type: "commit comment" end context 'when merge request comment event executed' do @@ -465,7 +465,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| Gitlab::DataBuilder::Note.build(merge_request_note, user) end - it_behaves_like "triggered #{service_name} service", event_type: "merge request comment" + it_behaves_like "triggered #{integration_name} integration", event_type: "merge request comment" end context 'when issue comment event executed' do @@ -478,7 +478,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| Gitlab::DataBuilder::Note.build(issue_note, user) end - it_behaves_like "triggered #{service_name} service", event_type: "issue comment" + it_behaves_like "triggered #{integration_name} integration", event_type: "issue comment" end context 'when snippet comment event executed' do @@ -491,7 +491,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| Gitlab::DataBuilder::Note.build(snippet_note, user) end - it_behaves_like "triggered #{service_name} service", event_type: "snippet comment" + it_behaves_like "triggered #{integration_name} integration", event_type: "snippet comment" end end @@ -505,7 +505,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| end before do - allow(chat_service).to receive_messages( + allow(chat_integration).to receive_messages( project: project, service_hook: true, webhook: webhook_url @@ -519,15 +519,15 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context 'with default to notify_only_broken_pipelines' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline" end context 'with setting notify_only_broken_pipelines to false' do before do - chat_service.notify_only_broken_pipelines = false + chat_integration.notify_only_broken_pipelines = false end - it_behaves_like "triggered #{service_name} service", event_type: "pipeline" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline" end end @@ -542,19 +542,19 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context 'notification enabled only for default branch' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "protected" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default_and_protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "all" end end @@ -572,19 +572,19 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context 'notification enabled only for default branch' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default_and_protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "all" end end @@ -602,19 +602,19 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context 'notification enabled only for default branch' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default_and_protected" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "all" end end @@ -628,19 +628,78 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |service_name| let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context 'notification enabled only for default branch' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default" end context 'notification enabled only for protected branches' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "protected" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "protected" end context 'notification enabled only for default and protected branches' do - it_behaves_like "untriggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "default_and_protected" + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default_and_protected" end context 'notification enabled for all branches' do - it_behaves_like "triggered #{service_name} service", event_type: "pipeline", branches_to_be_notified: "all" + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "all" + end + end + end + end + + describe 'Deployment events' do + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:project) { create(:project, :repository, creator: user) } + + let(:deployment) do + create(:deployment, :success, project: project, sha: project.commit.sha, ref: project.default_branch) + end + + let(:data) { Gitlab::DataBuilder::Deployment.build(deployment, Time.now) } + + before do + allow(chat_integration).to receive_messages( + project: project, + service_hook: true, + webhook: webhook_url + ) + + stub_full_request(webhook_url, method: :post) + end + + it_behaves_like "triggered #{integration_name} integration", event_type: "deployment" + + context 'on a protected branch' do + before do + create(:protected_branch, :create_branch_on_repository, project: project, name: 'a-protected-branch') + end + + let(:deployment) do + create(:deployment, :success, project: project, sha: project.commit.sha, ref: 'a-protected-branch') + end + + context 'notification enabled only for default branch' do + it_behaves_like "untriggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default" + end + + context 'notification enabled only for protected branches' do + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "protected" + end + + context 'notification enabled only for default and protected branches' do + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default_and_protected" + end + + context 'notification enabled for all branches' do + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "all" + end + + context 'when chat_notification_deployment_protected_branch_filter is disabled' do + before do + stub_feature_flags(chat_notification_deployment_protected_branch_filter: false) + end + + context 'notification enabled only for default branch' do + it_behaves_like "triggered #{integration_name} integration", event_type: "pipeline", branches_to_be_notified: "default" end end end diff --git a/spec/support/shared_examples/models/concerns/packages/destructible_shared_examples.rb b/spec/support/shared_examples/models/concerns/packages/destructible_shared_examples.rb new file mode 100644 index 00000000000..f974b46f881 --- /dev/null +++ b/spec/support/shared_examples/models/concerns/packages/destructible_shared_examples.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'destructible' do |factory:| + let_it_be(:item1) { create(factory, created_at: 1.month.ago, updated_at: 1.day.ago) } + let_it_be(:item2) { create(factory, created_at: 1.year.ago, updated_at: 1.year.ago) } + let_it_be(:item3) { create(factory, :pending_destruction, created_at: 2.years.ago, updated_at: 1.month.ago) } + let_it_be(:item4) { create(factory, :pending_destruction, created_at: 3.years.ago, updated_at: 2.weeks.ago) } + + describe '.next_pending_destruction' do + it 'returns the oldest item pending destruction based on updated_at' do + expect(described_class.next_pending_destruction(order_by: :updated_at)).to eq(item3) + end + + it 'returns the oldest item pending destruction based on created_at' do + expect(described_class.next_pending_destruction(order_by: :created_at)).to eq(item4) + end + end +end diff --git a/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb b/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb index 2d08de297a3..174b8609337 100644 --- a/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/ttl_expirable_shared_examples.rb @@ -29,7 +29,7 @@ RSpec.shared_examples 'ttl_expirable' do describe '.active' do # rubocop:disable Rails/SaveBang let_it_be(:item1) { create(class_symbol) } - let_it_be(:item2) { create(class_symbol, :expired) } + let_it_be(:item2) { create(class_symbol, :pending_destruction) } let_it_be(:item3) { create(class_symbol, status: :error) } # rubocop:enable Rails/SaveBang @@ -38,17 +38,6 @@ RSpec.shared_examples 'ttl_expirable' do end end - describe '.lock_next_by' do - let_it_be(:item1) { create(class_symbol, created_at: 1.month.ago, updated_at: 1.day.ago) } - let_it_be(:item2) { create(class_symbol, created_at: 1.year.ago, updated_at: 1.year.ago) } - let_it_be(:item3) { create(class_symbol, created_at: 2.years.ago, updated_at: 1.month.ago) } - - it 'returns the first item sorted by the argument' do - expect(described_class.lock_next_by(:updated_at)).to contain_exactly(item2) - expect(described_class.lock_next_by(:created_at)).to contain_exactly(item3) - end - end - describe '#read', :freeze_time do let_it_be(:old_read_at) { 1.day.ago } let_it_be(:item1) { create(class_symbol, read_at: old_read_at) } diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index d5d137922eb..5b4b8c8fcc1 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -3,7 +3,7 @@ RSpec.shared_examples 'inherited access level as a member of entity' do let(:parent_entity) { create(:group) } let(:user) { create(:user) } - let(:member) { entity.is_a?(Group) ? entity.group_member(user) : entity.project_member(user) } + let(:member) { entity.member(user) } context 'with root parent_entity developer member' do before do @@ -49,7 +49,7 @@ RSpec.shared_examples 'inherited access level as a member of entity' do entity.add_maintainer(non_member_user) - non_member = entity.is_a?(Group) ? entity.group_member(non_member_user) : entity.project_member(non_member_user) + non_member = entity.member(non_member_user) expect { non_member.update!(access_level: Gitlab::Access::GUEST) } .to change { non_member.reload.access_level } diff --git a/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb b/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb index 750d3dd11e3..3f8c3b8960b 100644 --- a/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb +++ b/spec/support/shared_examples/models/packages/debian/distribution_shared_examples.rb @@ -198,7 +198,6 @@ RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze| describe 'relationships' do it { is_expected.to have_many(:publications).class_name('Packages::Debian::Publication').inverse_of(:distribution).with_foreign_key(:distribution_id) } it { is_expected.to have_many(:packages).class_name('Packages::Package').through(:publications) } - it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile').through(:packages) } end end else @@ -229,6 +228,26 @@ RSpec.shared_examples 'Debian Distribution' do |factory, container, can_freeze| it 'returns only files from public packages with same codename' do expect(subject.to_a).to contain_exactly(*public_package_with_same_codename.package_files) end + + context 'with pending destruction package files' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: public_package_with_same_codename) } + + it 'does not return them' do + expect(subject.to_a).not_to include(package_file_pending_destruction) + end + + context 'with packages_installable_package_files disabled' do + before do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + subject + + expect(subject.to_a).to include(package_file_pending_destruction) + end + end + end end end end diff --git a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb index 2e01de2ea84..06326ffac97 100644 --- a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb +++ b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb @@ -115,16 +115,14 @@ RSpec.shared_examples 'UpdateProjectStatistics' do |with_counter_attribute| expect(ProjectStatistics) .not_to receive(:increment_statistic) - project.update!(pending_delete: true) - project.destroy! + expect(Projects::DestroyService.new(project, project.owner).execute).to eq(true) end it 'does not schedule a namespace statistics worker' do expect(Namespaces::ScheduleAggregationWorker) .not_to receive(:perform_async) - project.update!(pending_delete: true) - project.destroy! + expect(Projects::DestroyService.new(project, project.owner).execute).to eq(true) end end end diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index 3d52ed30c62..b43b7946e69 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -124,6 +124,18 @@ RSpec.shared_examples 'namespace traversal scopes' do it { expect(subject[0, 2]).to contain_exactly(group_1, group_2) } it { expect(subject[2, 2]).to contain_exactly(nested_group_1, nested_group_2) } end + + context 'with offset and limit' do + subject { described_class.where(id: [deep_nested_group_1, deep_nested_group_2]).offset(1).limit(1).self_and_ancestors } + + it { is_expected.to contain_exactly(group_2, nested_group_2, deep_nested_group_2) } + end + + context 'with upto' do + subject { described_class.where(id: deep_nested_group_1).self_and_ancestors(upto: nested_group_1.id) } + + it { is_expected.to contain_exactly(deep_nested_group_1) } + end end describe '.self_and_ancestors' do @@ -168,6 +180,19 @@ RSpec.shared_examples 'namespace traversal scopes' do it { is_expected.to contain_exactly(group_1.id, group_2.id) } end + + context 'with offset and limit' do + subject do + described_class + .where(id: [deep_nested_group_1, deep_nested_group_2]) + .limit(1) + .offset(1) + .self_and_ancestor_ids + .pluck(:id) + end + + it { is_expected.to contain_exactly(group_2.id, nested_group_2.id, deep_nested_group_2.id) } + end end describe '.self_and_ancestor_ids' do diff --git a/spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb index 017e55309f7..6cd871d354c 100644 --- a/spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb +++ b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true -RSpec.shared_examples 'project access tokens available #index' do - let_it_be(:active_project_access_token) { create(:personal_access_token, user: bot_user) } - let_it_be(:inactive_project_access_token) { create(:personal_access_token, :revoked, user: bot_user) } +RSpec.shared_examples 'GET resource access tokens available' do + let_it_be(:active_resource_access_token) { create(:personal_access_token, user: bot_user) } + let_it_be(:inactive_resource_access_token) { create(:personal_access_token, :revoked, user: bot_user) } - it 'retrieves active project access tokens' do + it 'retrieves active resource access tokens' do subject - expect(assigns(:active_project_access_tokens)).to contain_exactly(active_project_access_token) + expect(assigns(:active_resource_access_tokens)).to contain_exactly(active_resource_access_token) end - it 'retrieves inactive project access tokens' do + it 'retrieves inactive resource access tokens' do subject - expect(assigns(:inactive_project_access_tokens)).to contain_exactly(inactive_project_access_token) + expect(assigns(:inactive_resource_access_tokens)).to contain_exactly(inactive_resource_access_token) end it 'lists all available scopes' do @@ -24,15 +24,15 @@ RSpec.shared_examples 'project access tokens available #index' do it 'retrieves newly created personal access token value' do token_value = 'random-value' - allow(PersonalAccessToken).to receive(:redis_getdel).with("#{user.id}:#{project.id}").and_return(token_value) + allow(PersonalAccessToken).to receive(:redis_getdel).with("#{user.id}:#{resource.id}").and_return(token_value) subject - expect(assigns(:new_project_access_token)).to eq(token_value) + expect(assigns(:new_resource_access_token)).to eq(token_value) end end -RSpec.shared_examples 'project access tokens available #create' do +RSpec.shared_examples 'POST resource access tokens available' do def created_token PersonalAccessToken.order(:created_at).last end @@ -40,17 +40,17 @@ RSpec.shared_examples 'project access tokens available #create' do it 'returns success message' do subject - expect(controller).to set_flash[:notice].to match('Your new project access token has been created.') + expect(flash[:notice]).to match('Your new access token has been created.') end - it 'creates project access token' do + it 'creates resource access token' do access_level = access_token_params[:access_level] || Gitlab::Access::MAINTAINER subject expect(created_token.name).to eq(access_token_params[:name]) expect(created_token.scopes).to eq(access_token_params[:scopes]) expect(created_token.expires_at).to eq(access_token_params[:expires_at]) - expect(project.project_member(created_token.user).access_level).to eq(access_level) + expect(resource.member(created_token.user).access_level).to eq(access_level) end it 'creates project bot user' do @@ -90,12 +90,12 @@ RSpec.shared_examples 'project access tokens available #create' do it 'shows a failure alert' do subject - expect(controller).to set_flash[:alert].to match("Failed to create new project access token: Failed!") + expect(flash[:alert]).to match("Failed to create new access token: Failed!") end end end -RSpec.shared_examples 'project access tokens available #revoke' do +RSpec.shared_examples 'PUT resource access tokens available' do it 'calls delete user worker' do expect(DeleteUserWorker).to receive(:perform_async).with(user.id, bot_user.id, skip_authorization: true) @@ -105,7 +105,7 @@ RSpec.shared_examples 'project access tokens available #revoke' do it 'removes membership of bot user' do subject - expect(project.reload.bots).not_to include(bot_user) + expect(resource.reload.bots).not_to include(bot_user) end it 'converts issuables of the bot user to ghost user' do @@ -121,4 +121,18 @@ RSpec.shared_examples 'project access tokens available #revoke' do expect(User.exists?(bot_user.id)).to be_falsy end + + context 'when unsuccessful' do + before do + allow_next_instance_of(ResourceAccessTokens::RevokeService) do |service| + allow(service).to receive(:execute).and_return ServiceResponse.error(message: 'Failed!') + end + end + + it 'shows a failure alert' do + subject + + expect(flash[:alert]).to include("Could not revoke access token") + end + end end diff --git a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb index 2fd5e6a5f91..9f96cb2a164 100644 --- a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb @@ -40,7 +40,6 @@ RSpec.shared_examples 'Debian packages upload request' do |status, body = nil| expect(response.body).to match(body) end end - it_behaves_like 'a package tracking event', described_class.name, 'push_package' else it "returns #{status}#{and_body}", :aggregate_failures do subject diff --git a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb index d576a5874fd..9385706d991 100644 --- a/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/packages/package_details_shared_examples.rb @@ -38,4 +38,28 @@ RSpec.shared_examples 'a package with files' do 'fileSha256' => first_file.file_sha256 ) end + + context 'with package files pending destruction' do + let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: package) } + + let(:response_package_file_ids) { package_files_response.map { |pf| pf['id'] } } + + it 'does not return them' do + expect(package.reload.package_files).to include(package_file_pending_destruction) + + expect(response_package_file_ids).not_to include(package_file_pending_destruction.to_global_id.to_s) + end + + context 'with packages_installable_package_files disabled' do + before(:context) do + stub_feature_flags(packages_installable_package_files: false) + end + + it 'returns them' do + expect(package.reload.package_files).to include(package_file_pending_destruction) + + expect(response_package_file_ids).to include(package_file_pending_destruction.to_global_id.to_s) + end + end + end end diff --git a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb index db70bc75c63..290bf58fb6b 100644 --- a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb @@ -221,6 +221,7 @@ RSpec.shared_examples 'handling nuget search requests' do |anonymous_requests_ex let_it_be(:packages_c) { create_list(:nuget_package, 5, name: 'Dummy.PackageC', project: project) } let_it_be(:package_d) { create(:nuget_package, name: 'Dummy.PackageD', version: '5.0.5-alpha', project: project) } let_it_be(:package_e) { create(:nuget_package, name: 'Foo.BarE', project: project) } + let(:search_term) { 'uMmy' } let(:take) { 26 } let(:skip) { 0 } diff --git a/spec/support/shared_examples/services/alert_management_shared_examples.rb b/spec/support/shared_examples/services/alert_management_shared_examples.rb index 827ae42f970..23aee912d2d 100644 --- a/spec/support/shared_examples/services/alert_management_shared_examples.rb +++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb @@ -64,12 +64,16 @@ RSpec.shared_examples 'processes never-before-seen recovery alert' do end RSpec.shared_examples 'processes one firing and one resolved prometheus alerts' do - it 'creates AlertManagement::Alert' do + it 'creates alerts and returns them in the payload', :aggregate_failures do expect(Gitlab::AppLogger).not_to receive(:warn) expect { subject } .to change(AlertManagement::Alert, :count).by(2) .and change(Note, :count).by(4) + + expect(subject).to be_success + expect(subject.payload[:alerts]).to all(be_a_kind_of(AlertManagement::Alert)) + expect(subject.payload[:alerts].size).to eq(2) end it_behaves_like 'processes incident issues' diff --git a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb index f6e25ee6647..87bf134eeb8 100644 --- a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb +++ b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb @@ -71,6 +71,7 @@ end RSpec.shared_examples 'an accessible' do before do stub_feature_flags(container_registry_migration_phase1: false) + stub_feature_flags(container_registry_cdn_redirect: false) end let(:access) do @@ -163,6 +164,7 @@ RSpec.shared_examples 'a container registry auth service' do before do stub_feature_flags(container_registry_migration_phase1: false) + stub_feature_flags(container_registry_cdn_redirect: false) end describe '#full_access_token' do diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb index 0277cce975a..36b0acf5a51 100644 --- a/spec/support/shared_examples/services/incident_shared_examples.rb +++ b/spec/support/shared_examples/services/incident_shared_examples.rb @@ -17,16 +17,6 @@ RSpec.shared_examples 'incident issue' do end end -RSpec.shared_examples 'has incident label' do - let(:label_properties) { attributes_for(:label, :incident) } - - it 'has exactly one incident label' do - expect(issue.labels).to be_one do |label| - label.slice(*label_properties.keys).symbolize_keys == label_properties - end - end -end - # This shared_example requires the following variables: # - issue (required) # @@ -45,6 +35,12 @@ RSpec.shared_examples 'not an incident issue' do expect(issue.work_item_type.base_type).not_to eq('incident') end + it_behaves_like 'does not have incident label' +end + +RSpec.shared_examples 'does not have incident label' do + let(:label_properties) { attributes_for(:label, :incident) } + it 'has not an incident label' do expect(issue.labels).not_to include(have_attributes(label_properties)) end diff --git a/spec/support/shared_examples/services/service_ping/service_ping_payload_with_all_expected_metrics_shared_examples.rb b/spec/support/shared_examples/services/service_ping/service_ping_payload_with_all_expected_metrics_shared_examples.rb index 535e7291b7e..856810a4de1 100644 --- a/spec/support/shared_examples/services/service_ping/service_ping_payload_with_all_expected_metrics_shared_examples.rb +++ b/spec/support/shared_examples/services/service_ping/service_ping_payload_with_all_expected_metrics_shared_examples.rb @@ -2,6 +2,8 @@ RSpec.shared_examples 'service ping payload with all expected metrics' do specify do + allow(ApplicationRecord.database).to receive(:flavor).and_return(nil) + aggregate_failures do expected_metrics.each do |metric| is_expected.to have_usage_metric metric['key_path'] diff --git a/spec/support/shared_examples/services/service_ping/service_ping_payload_without_restricted_metrics_shared_examples.rb b/spec/support/shared_examples/services/service_ping/service_ping_payload_without_restricted_metrics_shared_examples.rb index 9f18174cbc7..e05239a9a36 100644 --- a/spec/support/shared_examples/services/service_ping/service_ping_payload_without_restricted_metrics_shared_examples.rb +++ b/spec/support/shared_examples/services/service_ping/service_ping_payload_without_restricted_metrics_shared_examples.rb @@ -2,6 +2,8 @@ RSpec.shared_examples 'service ping payload without restricted metrics' do specify do + allow(ApplicationRecord.database).to receive(:flavor).and_return(nil) + aggregate_failures do restricted_metrics.each do |metric| is_expected.not_to have_usage_metric metric['key_path'] diff --git a/spec/support/shared_examples/work_item_base_types_importer.rb b/spec/support/shared_examples/work_item_base_types_importer.rb index 7d652be8d05..68e37171ea2 100644 --- a/spec/support/shared_examples/work_item_base_types_importer.rb +++ b/spec/support/shared_examples/work_item_base_types_importer.rb @@ -3,8 +3,8 @@ RSpec.shared_examples 'work item base types importer' do it 'creates all base work item types' do # Fixtures need to run on a pristine DB, but the test suite preloads the base types before(:suite) - WorkItem::Type.delete_all + WorkItems::Type.delete_all - expect { subject }.to change(WorkItem::Type, :count).from(0).to(WorkItem::Type::BASE_TYPES.count) + expect { subject }.to change(WorkItems::Type, :count).from(0).to(WorkItems::Type::BASE_TYPES.count) end end diff --git a/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb b/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb index c9014ad549c..26444437826 100644 --- a/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb +++ b/spec/support/shared_examples/workers/concerns/dependency_proxy/cleanup_worker_shared_examples.rb @@ -13,12 +13,12 @@ RSpec.shared_examples 'dependency_proxy_cleanup_worker' do end context 'with work to do' do - let_it_be(:artifact1) { create(factory_type, :expired, group: group) } - let_it_be(:artifact2) { create(factory_type, :expired, group: group, updated_at: 6.months.ago, created_at: 2.years.ago) } - let_it_be_with_reload(:artifact3) { create(factory_type, :expired, group: group, updated_at: 1.year.ago, created_at: 1.year.ago) } + let_it_be(:artifact1) { create(factory_type, :pending_destruction, group: group) } + let_it_be(:artifact2) { create(factory_type, :pending_destruction, group: group, updated_at: 6.months.ago, created_at: 2.years.ago) } + let_it_be_with_reload(:artifact3) { create(factory_type, :pending_destruction, group: group, updated_at: 1.year.ago, created_at: 1.year.ago) } let_it_be(:artifact4) { create(factory_type, group: group, updated_at: 2.years.ago, created_at: 2.years.ago) } - it 'deletes the oldest expired artifact based on updated_at', :aggregate_failures do + it 'deletes the oldest artifact pending destruction based on updated_at', :aggregate_failures do expect(worker).to receive(:log_extra_metadata_on_done).with("#{factory_type}_id".to_sym, artifact3.id) expect(worker).to receive(:log_extra_metadata_on_done).with(:group_id, group.id) @@ -40,10 +40,8 @@ RSpec.shared_examples 'dependency_proxy_cleanup_worker' do end describe '#remaining_work_count' do - let_it_be(:expired_artifacts) do - (1..3).map do |_| - create(factory_type, :expired, group: group) - end + before(:context) do + create_list(factory_type, 3, :pending_destruction, group: group) end subject { worker.remaining_work_count } diff --git a/spec/support/system_exit_detected.rb b/spec/support/system_exit_detected.rb new file mode 100644 index 00000000000..86c6af3ba8c --- /dev/null +++ b/spec/support/system_exit_detected.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +SystemExitDetected = Class.new(RuntimeError) + +RSpec.configure do |config| + config.around do |example| + example.run + rescue SystemExit + # In any cases, we cannot raise SystemExit in the tests, + # because it'll skip any following tests from running. + # Convert it to something that won't skip everything. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/350060 + raise SystemExitDetected, "SystemExit should be rescued in the tests!" + end +end diff --git a/spec/support_specs/database/multiple_databases_spec.rb b/spec/support_specs/database/multiple_databases_spec.rb index a8692e315fe..b4cfa253813 100644 --- a/spec/support_specs/database/multiple_databases_spec.rb +++ b/spec/support_specs/database/multiple_databases_spec.rb @@ -7,13 +7,13 @@ RSpec.describe 'Database::MultipleDatabases' do context 'when doing establish_connection' do context 'on ActiveRecord::Base' do it 'raises exception' do - expect { ActiveRecord::Base.establish_connection(:main) }.to raise_error /Cannot re-establish/ + expect { ActiveRecord::Base.establish_connection(:main) }.to raise_error /Cannot re-establish/ # rubocop: disable Database/EstablishConnection end context 'when using with_reestablished_active_record_base' do it 'does not raise exception' do with_reestablished_active_record_base do - expect { ActiveRecord::Base.establish_connection(:main) }.not_to raise_error + expect { ActiveRecord::Base.establish_connection(:main) }.not_to raise_error # rubocop: disable Database/EstablishConnection end end end @@ -25,13 +25,13 @@ RSpec.describe 'Database::MultipleDatabases' do end it 'raises exception' do - expect { Ci::ApplicationRecord.establish_connection(:ci) }.to raise_error /Cannot re-establish/ + expect { Ci::ApplicationRecord.establish_connection(:ci) }.to raise_error /Cannot re-establish/ # rubocop: disable Database/EstablishConnection end context 'when using with_reestablished_active_record_base' do it 'does not raise exception' do with_reestablished_active_record_base do - expect { Ci::ApplicationRecord.establish_connection(:main) }.not_to raise_error + expect { Ci::ApplicationRecord.establish_connection(:main) }.not_to raise_error # rubocop: disable Database/EstablishConnection end end end @@ -42,7 +42,7 @@ RSpec.describe 'Database::MultipleDatabases' do context 'when reconnect is true' do it 'does not raise exception' do with_reestablished_active_record_base(reconnect: true) do - expect { ActiveRecord::Base.connection.execute("SELECT 1") }.not_to raise_error # rubocop:disable Database/MultipleDatabases + expect { ApplicationRecord.connection.execute("SELECT 1") }.not_to raise_error end end end @@ -50,7 +50,7 @@ RSpec.describe 'Database::MultipleDatabases' do context 'when reconnect is false' do it 'does raise exception' do with_reestablished_active_record_base(reconnect: false) do - expect { ActiveRecord::Base.connection.execute("SELECT 1") }.to raise_error(ActiveRecord::ConnectionNotEstablished) # rubocop:disable Database/MultipleDatabases + expect { ApplicationRecord.connection.execute("SELECT 1") }.to raise_error(ActiveRecord::ConnectionNotEstablished) end end end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index 99deaa8d154..c5e73aa3b45 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -4,6 +4,7 @@ require 'rake_helper' RSpec.describe 'gitlab:app namespace rake task', :delete do let(:enable_registry) { true } + let(:backup_types) { %w{db repo uploads builds artifacts pages lfs terraform_state registry packages} } def tars_glob Dir.glob(File.join(Gitlab.config.backup.path, '*_gitlab_backup.tar')) @@ -14,7 +15,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do end def backup_files - %w(backup_information.yml artifacts.tar.gz builds.tar.gz lfs.tar.gz pages.tar.gz) + %w(backup_information.yml artifacts.tar.gz builds.tar.gz lfs.tar.gz terraform_state.tar.gz pages.tar.gz packages.tar.gz) end def backup_directories @@ -47,7 +48,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do end def reenable_backup_sub_tasks - %w{db repo uploads builds artifacts pages lfs registry}.each do |subtask| + backup_types.each do |subtask| Rake::Task["gitlab:backup:#{subtask}:create"].reenable end end @@ -71,14 +72,9 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do allow(YAML).to receive(:load_file) .and_return({ gitlab_version: gitlab_version }) expect(Rake::Task['gitlab:db:drop_tables']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:db:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:repo:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:builds:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:uploads:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke) - expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke) + backup_types.each do |subtask| + expect(Rake::Task["gitlab:backup:#{subtask}:restore"]).to receive(:invoke) + end expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) end @@ -95,7 +91,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do context 'when the restore directory is not empty' do before do # We only need a backup of the repositories for this test - stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry') + stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,terraform_state,registry') create(:project, :repository) end @@ -139,11 +135,10 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive(:invoke) expect(Rake::Task['gitlab:backup:pages:restore']).to receive(:invoke) expect(Rake::Task['gitlab:backup:lfs:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:terraform_state:restore']).to receive(:invoke) expect(Rake::Task['gitlab:backup:registry:restore']).to receive(:invoke) + expect(Rake::Task['gitlab:backup:packages:restore']).to receive(:invoke) expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke) - - # We only need a backup of the repositories for this test - stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry') end it 'restores the data' do @@ -202,10 +197,8 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do end context 'specific backup tasks' do - let(:task_list) { %w(db repo uploads builds artifacts pages lfs registry) } - it 'prints a progress message to stdout' do - task_list.each do |task| + backup_types.each do |task| expect { run_rake_task("gitlab:backup:#{task}:create") }.to output(/Dumping /).to_stdout_from_any_process end end @@ -219,16 +212,49 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping artifacts ... ") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping pages ... ") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping lfs objects ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping terraform states ... ") expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping container registry images ... ") - expect(Gitlab::BackupLogger).to receive(:info).with(message: "done").exactly(7).times + expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping packages ... ") + expect(Gitlab::BackupLogger).to receive(:info).with(message: "done").exactly(9).times - task_list.each do |task| + backup_types.each do |task| run_rake_task("gitlab:backup:#{task}:create") end end end end + describe 'backup create fails' do + using RSpec::Parameterized::TableSyntax + + file_backup_error = Backup::FileBackupError.new('/tmp', '/tmp/backup/uploads') + config = ActiveRecord::Base.configurations.find_db_config(Rails.env).configuration_hash + db_file_name = File.join(Gitlab.config.backup.path, 'db', 'database.sql.gz') + db_backup_error = Backup::DatabaseBackupError.new(config, db_file_name) + + where(:backup_class, :rake_task, :error) do + Backup::Database | 'gitlab:backup:db:create' | db_backup_error + Backup::Builds | 'gitlab:backup:builds:create' | file_backup_error + Backup::Uploads | 'gitlab:backup:uploads:create' | file_backup_error + Backup::Artifacts | 'gitlab:backup:artifacts:create' | file_backup_error + Backup::Pages | 'gitlab:backup:pages:create' | file_backup_error + Backup::Lfs | 'gitlab:backup:lfs:create' | file_backup_error + Backup::Registry | 'gitlab:backup:registry:create' | file_backup_error + end + + with_them do + before do + expect_next_instance_of(backup_class) do |instance| + expect(instance).to receive(:dump).and_raise(error) + end + end + + it "raises an error with message" do + expect { run_rake_task(rake_task) }.to output(Regexp.new(error.message)).to_stdout_from_any_process + end + end + end + context 'tar creation' do context 'archive file permissions' do it 'sets correct permissions on the tar file' do @@ -255,9 +281,11 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process tar_contents, exit_status = Gitlab::Popen.popen( - %W{tar -tvf #{backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz registry.tar.gz} + %W{tar -tvf #{backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz terraform_state.tar.gz registry.tar.gz packages.tar.gz} ) + puts "CONTENT: #{tar_contents}" + expect(exit_status).to eq(0) expect(tar_contents).to match('db') expect(tar_contents).to match('uploads.tar.gz') @@ -266,7 +294,9 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do expect(tar_contents).to match('artifacts.tar.gz') expect(tar_contents).to match('pages.tar.gz') expect(tar_contents).to match('lfs.tar.gz') + expect(tar_contents).to match('terraform_state.tar.gz') expect(tar_contents).to match('registry.tar.gz') + expect(tar_contents).to match('packages.tar.gz') expect(tar_contents).not_to match(%r{^.{4,9}[rwx].* (database.sql.gz|uploads.tar.gz|repositories|builds.tar.gz|pages.tar.gz|artifacts.tar.gz|registry.tar.gz)/$}) end @@ -274,7 +304,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process temp_dirs = Dir.glob( - File.join(Gitlab.config.backup.path, '{db,repositories,uploads,builds,artifacts,pages,lfs,registry}') + File.join(Gitlab.config.backup.path, '{db,repositories,uploads,builds,artifacts,pages,lfs,terraform_state,registry,packages}') ) expect(temp_dirs).to be_empty @@ -304,7 +334,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do before do # We only need a backup of the repositories for this test - stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry') + stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,terraform_state,registry') stub_storage_settings( second_storage_name => { 'gitaly_address' => Gitlab.config.repositories.storages.default.gitaly_address, 'path' => TestEnv::SECOND_STORAGE_PATH @@ -378,7 +408,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do context 'concurrency settings' do before do # We only need a backup of the repositories for this test - stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,registry') + stub_env('SKIP', 'db,uploads,builds,artifacts,lfs,terraform_state,registry') create(:project, :repository) end @@ -407,7 +437,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do .with(max_concurrency: 5, max_storage_concurrency: 2) .and_call_original end - expect(::Backup::GitalyBackup).to receive(:new).with(anything, parallel: 5, parallel_storage: 2).and_call_original + expect(::Backup::GitalyBackup).to receive(:new).with(anything, max_parallelism: 5, storage_parallelism: 2).and_call_original expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process end @@ -425,31 +455,34 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do end # backup_create task - describe "Skipping items" do + describe "Skipping items in a backup" do before do - stub_env('SKIP', 'repositories,uploads') + stub_env('SKIP', 'an-unknown-type,repositories,uploads,anotherunknowntype') create(:project, :repository) end - it "does not contain skipped item" do + it "does not contain repositories and uploads" do expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process tar_contents, _exit_status = Gitlab::Popen.popen( - %W{tar -tvf #{backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz registry.tar.gz} + %W{tar -tvf #{backup_tar} db uploads.tar.gz repositories builds.tar.gz artifacts.tar.gz pages.tar.gz lfs.tar.gz terraform_state.tar.gz registry.tar.gz packages.tar.gz} ) expect(tar_contents).to match('db/') - expect(tar_contents).to match('uploads.tar.gz') + expect(tar_contents).to match('uploads.tar.gz: Not found in archive') expect(tar_contents).to match('builds.tar.gz') expect(tar_contents).to match('artifacts.tar.gz') expect(tar_contents).to match('lfs.tar.gz') + expect(tar_contents).to match('terraform_state.tar.gz') expect(tar_contents).to match('pages.tar.gz') expect(tar_contents).to match('registry.tar.gz') + expect(tar_contents).to match('packages.tar.gz') expect(tar_contents).not_to match('repositories/') + expect(tar_contents).to match('repositories: Not found in archive') end - it 'does not invoke repositories restore' do + it 'does not invoke restore of repositories and uploads' do expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process allow(Rake::Task['gitlab:shell:setup']) @@ -463,7 +496,9 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive :invoke expect(Rake::Task['gitlab:backup:pages:restore']).to receive :invoke expect(Rake::Task['gitlab:backup:lfs:restore']).to receive :invoke + expect(Rake::Task['gitlab:backup:terraform_state:restore']).to receive :invoke expect(Rake::Task['gitlab:backup:registry:restore']).to receive :invoke + expect(Rake::Task['gitlab:backup:packages:restore']).to receive :invoke expect(Rake::Task['gitlab:shell:setup']).to receive :invoke expect { run_rake_task('gitlab:backup:restore') }.to output.to_stdout_from_any_process end @@ -488,8 +523,10 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do 'builds.tar.gz', 'artifacts.tar.gz', 'lfs.tar.gz', + 'terraform_state.tar.gz', 'pages.tar.gz', 'registry.tar.gz', + 'packages.tar.gz', 'repositories' ) end @@ -501,14 +538,9 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do .to receive(:invoke).and_return(true) expect(Rake::Task['gitlab:db:drop_tables']).to receive :invoke - expect(Rake::Task['gitlab:backup:db:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:repo:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:uploads:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:builds:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:artifacts:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:pages:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:lfs:restore']).to receive :invoke - expect(Rake::Task['gitlab:backup:registry:restore']).to receive :invoke + backup_types.each do |subtask| + expect(Rake::Task["gitlab:backup:#{subtask}:restore"]).to receive :invoke + end expect(Rake::Task['gitlab:shell:setup']).to receive :invoke expect { run_rake_task("gitlab:backup:restore") }.to output.to_stdout_from_any_process end diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb index 830d0dded2e..92c896b1ab0 100644 --- a/spec/tasks/gitlab/db_rake_spec.rb +++ b/spec/tasks/gitlab/db_rake_spec.rb @@ -214,7 +214,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false) expect(Gitlab::Database::Reindexing).not_to receive(:invoke) - run_rake_task('gitlab:db:reindex') + expect { run_rake_task('gitlab:db:reindex') }.to raise_error(SystemExit) end end end @@ -233,7 +233,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false) expect(Gitlab::Database::Reindexing).not_to receive(:invoke).with(database_name) - run_rake_task("gitlab:db:reindex:#{database_name}") + expect { run_rake_task("gitlab:db:reindex:#{database_name}") }.to raise_error(SystemExit) end end end diff --git a/spec/tasks/gitlab/password_rake_spec.rb b/spec/tasks/gitlab/password_rake_spec.rb index 65bba836024..ec18d713351 100644 --- a/spec/tasks/gitlab/password_rake_spec.rb +++ b/spec/tasks/gitlab/password_rake_spec.rb @@ -3,7 +3,7 @@ require 'rake_helper' RSpec.describe 'gitlab:password rake tasks', :silence_stdout do - let_it_be(:user_1) { create(:user, username: 'foobar', password: 'initial_password') } + let_it_be(:user_1) { create(:user, username: 'foobar', password: Gitlab::Password.test_default) } def stub_username(username) allow(Gitlab::TaskHelpers).to receive(:prompt).with('Enter username: ').and_return(username) @@ -19,14 +19,14 @@ RSpec.describe 'gitlab:password rake tasks', :silence_stdout do Rake.application.rake_require 'tasks/gitlab/password' stub_username('foobar') - stub_password('secretpassword') + stub_password(Gitlab::Password.test_default) end describe ':reset' do context 'when all inputs are correct' do it 'updates the password properly' do run_rake_task('gitlab:password:reset', user_1.username) - expect(user_1.reload.valid_password?('secretpassword')).to eq(true) + expect(user_1.reload.valid_password?(Gitlab::Password.test_default)).to eq(true) end end @@ -55,7 +55,7 @@ RSpec.describe 'gitlab:password rake tasks', :silence_stdout do context 'when passwords do not match' do before do - stub_password('randompassword', 'differentpassword') + stub_password(Gitlab::Password.test_default, "different" + Gitlab::Password.test_default) end it 'aborts with an error' do diff --git a/spec/tasks/gitlab/usage_data_rake_spec.rb b/spec/tasks/gitlab/usage_data_rake_spec.rb index acaf9b5729b..442b884b313 100644 --- a/spec/tasks/gitlab/usage_data_rake_spec.rb +++ b/spec/tasks/gitlab/usage_data_rake_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'gitlab:usage data take tasks', :silence_stdout do Rake.application.rake_require 'tasks/gitlab/usage_data' # stub prometheus external http calls https://gitlab.com/gitlab-org/gitlab/-/issues/245277 stub_prometheus_queries + stub_database_flavor_check end describe 'dump_sql_in_yaml' do diff --git a/spec/tooling/danger/datateam_spec.rb b/spec/tooling/danger/datateam_spec.rb new file mode 100644 index 00000000000..3bcef3ac886 --- /dev/null +++ b/spec/tooling/danger/datateam_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rspec-parameterized' +require 'gitlab-dangerfiles' +require 'gitlab/dangerfiles/spec_helper' +require 'pry' +require_relative '../../../tooling/danger/datateam' + +RSpec.describe Tooling::Danger::Datateam do + include_context "with dangerfile" + + let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) } + let(:datateam) { fake_danger.new(helper: fake_helper) } + + describe 'data team danger' do + using RSpec::Parameterized::TableSyntax + + where do + { + 'with structure.sql changes and no Data Warehouse::Impact Check label' => { + modified_files: %w(db/structure.sql app/models/user.rb), + changed_lines: ['+group_id bigint NOT NULL'], + mr_labels: [], + impacted: true, + impacted_files: %w(db/structure.sql) + }, + 'with structure.sql changes and Data Warehouse::Impact Check label' => { + modified_files: %w(db/structure.sql), + changed_lines: ['+group_id bigint NOT NULL)'], + mr_labels: ['Data Warehouse::Impact Check'], + impacted: false, + impacted_files: %w(db/structure.sql) + }, + 'with user model changes' => { + modified_files: %w(app/models/users.rb), + changed_lines: ['+has_one :namespace'], + mr_labels: [], + impacted: false, + impacted_files: [] + }, + 'with perfomance indicator changes and no Data Warehouse::Impact Check label' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml app/models/user.rb), + changed_lines: ['+-gmau'], + mr_labels: [], + impacted: true, + impacted_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml) + }, + 'with perfomance indicator changes and Data Warehouse::Impact Check label' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml), + changed_lines: ['+-gmau'], + mr_labels: ['Data Warehouse::Impact Check'], + impacted: false, + impacted_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml) + }, + 'with metric file changes and no performance indicator changes' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml), + changed_lines: ['-product_stage: growth'], + mr_labels: [], + impacted: false, + impacted_files: [] + }, + 'with metric file changes and no performance indicator changes and other label' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml), + changed_lines: ['-product_stage: growth'], + mr_labels: ['type::tooling'], + impacted: false, + impacted_files: [] + }, + 'with performance indicator changes and other label' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml app/models/user.rb), + changed_lines: ['+-gmau'], + mr_labels: ['type::tooling'], + impacted: true, + impacted_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml) + }, + 'with performance indicator changes, Data Warehouse::Impact Check and other label' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml app/models/user.rb), + changed_lines: ['+-gmau'], + mr_labels: ['type::tooling', 'Data Warehouse::Impact Check'], + impacted: false, + impacted_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml) + }, + 'with performance indicator changes and other labels' => { + modified_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml app/models/user.rb), + changed_lines: ['+-gmau'], + mr_labels: ['type::tooling', 'Data Warehouse::Impacted'], + impacted: false, + impacted_files: %w(config/metrics/20210216182127_user_secret_detection_jobs.yml) + } + } + end + + with_them do + before do + allow(fake_helper).to receive(:modified_files).and_return(modified_files) + allow(fake_helper).to receive(:changed_lines).and_return(changed_lines) + allow(fake_helper).to receive(:mr_labels).and_return(mr_labels) + allow(fake_helper).to receive(:markdown_list).with(impacted_files).and_return(impacted_files.map { |item| "* `#{item}`" }.join("\n")) + end + + it :aggregate_failures do + expect(datateam.impacted?).to be(impacted) + expect(datateam.build_message).to match_expected_message + end + end + end + + def match_expected_message + return be_nil unless impacted + + start_with(described_class::CHANGED_SCHEMA_MESSAGE).and(include(*impacted_files)) + end +end diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb index f13083bdf0a..52aa90beb2b 100644 --- a/spec/tooling/danger/project_helper_spec.rb +++ b/spec/tooling/danger/project_helper_spec.rb @@ -269,7 +269,7 @@ RSpec.describe Tooling::Danger::ProjectHelper do describe '.local_warning_message' do it 'returns an informational message with rules that can run' do - expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, ci_config, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation') + expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changelog, ci_config, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation, datateam') end end diff --git a/spec/tooling/docs/deprecation_handling_spec.rb b/spec/tooling/docs/deprecation_handling_spec.rb new file mode 100644 index 00000000000..e389fe882b2 --- /dev/null +++ b/spec/tooling/docs/deprecation_handling_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative '../../fast_spec_helper' +require_relative '../../../tooling/docs/deprecation_handling' +require_relative '../../support/helpers/next_instance_of' + +RSpec.describe Docs::DeprecationHandling do + include ::NextInstanceOf + + let(:type) { 'deprecation' } + + subject { described_class.new(type).render } + + before do + allow(Rake::FileList).to receive(:new).and_return( + ['14-10-c.yml', '14-2-b.yml', '14-2-a.yml'] + ) + # Create dummy YAML data based on file name + allow(YAML).to receive(:load_file) do |file_name| + { + 'name' => file_name[/[a-z]*\.yml/], + 'announcement_milestone' => file_name[/\d+-\d+/].tr('-', '.') + } + end + end + + it 'sorts entries and milestones' do + allow_next_instance_of(ERB) do |template| + expect(template).to receive(:result_with_hash) do |arguments| + milestones = arguments[:milestones] + entries = arguments[:entries] + + expect(milestones).to eq(['14.2', '14.10']) + expect(entries.map { |e| e['name'] }).to eq(['a.yml', 'b.yml', 'c.yml']) + end + end + + subject + end +end diff --git a/spec/uploaders/ci/secure_file_uploader_spec.rb b/spec/uploaders/ci/secure_file_uploader_spec.rb new file mode 100644 index 00000000000..3be4f742a24 --- /dev/null +++ b/spec/uploaders/ci/secure_file_uploader_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::SecureFileUploader do + subject { ci_secure_file.file } + + let(:project) { create(:project) } + let(:ci_secure_file) { create(:ci_secure_file) } + let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') } + + before do + stub_ci_secure_file_object_storage + end + + describe '#key' do + it 'creates a digest with a secret key and the project id' do + expect(OpenSSL::HMAC) + .to receive(:digest) + .with('SHA256', Gitlab::Application.secrets.db_key_base, ci_secure_file.project_id.to_s) + .and_return('digest') + + expect(subject.key).to eq('digest') + end + end + + describe '.checksum' do + it 'returns a SHA256 checksum for the unencrypted file' do + expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file)) + end + end + + describe 'encryption' do + it 'encrypts the stored file' do + expect(Base64.encode64(subject.file.read)).not_to eq(Base64.encode64(sample_file)) + end + + it 'decrypts the file when reading' do + expect(Base64.encode64(subject.read)).to eq(Base64.encode64(sample_file)) + end + end + + describe '.direct_upload_enabled?' do + it 'returns false' do + expect(described_class.direct_upload_enabled?).to eq(false) + end + end + + describe '.background_upload_enabled?' do + it 'returns false' do + expect(described_class.background_upload_enabled?).to eq(false) + end + end + + describe '.default_store' do + context 'when object storage is enabled' do + it 'returns REMOTE' do + expect(described_class.default_store).to eq(ObjectStorage::Store::REMOTE) + end + end + + context 'when object storage is disabled' do + before do + stub_ci_secure_file_object_storage(enabled: false) + end + + it 'returns LOCAL' do + expect(described_class.default_store).to eq(ObjectStorage::Store::LOCAL) + end + end + end +end diff --git a/spec/views/admin/dashboard/index.html.haml_spec.rb b/spec/views/admin/dashboard/index.html.haml_spec.rb index 9fa95613d1c..9db2bd3741a 100644 --- a/spec/views/admin/dashboard/index.html.haml_spec.rb +++ b/spec/views/admin/dashboard/index.html.haml_spec.rb @@ -53,11 +53,14 @@ RSpec.describe 'admin/dashboard/index.html.haml' do expect(rendered).not_to have_content "Users over License" end - it 'links to the GitLab Changelog' do - stub_application_setting(version_check_enabled: true) - - render + describe 'when show_version_check? is true' do + before do + allow(view).to receive(:show_version_check?).and_return(true) + render + end - expect(rendered).to have_link(href: 'https://gitlab.com/gitlab-org/gitlab/-/blob/master/CHANGELOG.md') + it 'renders the version check badge' do + expect(rendered).to have_selector('.js-gitlab-version-check') + end end end diff --git a/spec/views/groups/edit.html.haml_spec.rb b/spec/views/groups/edit.html.haml_spec.rb index 43e11d31611..eaa909a5da0 100644 --- a/spec/views/groups/edit.html.haml_spec.rb +++ b/spec/views/groups/edit.html.haml_spec.rb @@ -115,4 +115,52 @@ RSpec.describe 'groups/edit.html.haml' do end end end + + context 'ip_restriction' do + let(:group) { create(:group) } + let(:user) { create(:user) } + + before do + group.add_owner(user) + + assign(:group, group) + allow(view).to receive(:current_user) { user } + end + + context 'prompt user about registration features' do + before do + if Gitlab.ee? + allow(License).to receive(:current).and_return(nil) + end + end + + context 'with service ping disabled' do + before do + stub_application_setting(usage_ping_enabled: false) + end + + it 'renders a placeholder input with registration features message' do + render + + expect(rendered).to have_field(:group_disabled_ip_restriction_ranges, disabled: true) + expect(rendered).to have_content(s_("RegistrationFeatures|Want to %{feature_title} for free?") % { feature_title: s_('RegistrationFeatures|use this feature') }) + expect(rendered).to have_link(s_('RegistrationFeatures|Registration Features Program')) + end + end + + context 'with service ping enabled' do + before do + stub_application_setting(usage_ping_enabled: true) + end + + it 'does not render a placeholder input with registration features message' do + render + + expect(rendered).not_to have_field(:group_disabled_ip_restriction_ranges, disabled: true) + expect(rendered).not_to have_content(s_("RegistrationFeatures|Want to %{feature_title} for free?") % { feature_title: s_('RegistrationFeatures|use this feature') }) + expect(rendered).not_to have_link(s_('RegistrationFeatures|Registration Features Program')) + end + end + end + end end diff --git a/spec/views/help/index.html.haml_spec.rb b/spec/views/help/index.html.haml_spec.rb index 600e431b7ef..1d26afcc567 100644 --- a/spec/views/help/index.html.haml_spec.rb +++ b/spec/views/help/index.html.haml_spec.rb @@ -76,7 +76,6 @@ RSpec.describe 'help/index' do def stub_helpers allow(view).to receive(:markdown).and_return('') - allow(view).to receive(:version_status_badge).and_return('') allow(view).to receive(:current_application_settings).and_return(Gitlab::CurrentSettings.current_application_settings) end end diff --git a/spec/views/layouts/header/_gitlab_version.html.haml_spec.rb b/spec/views/layouts/header/_gitlab_version.html.haml_spec.rb new file mode 100644 index 00000000000..0e24810f835 --- /dev/null +++ b/spec/views/layouts/header/_gitlab_version.html.haml_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'layouts/header/_gitlab_version' do + describe 'when show_version_check? is true' do + before do + allow(view).to receive(:show_version_check?).and_return(true) + render + end + + it 'renders the version check badge' do + expect(rendered).to have_selector('.js-gitlab-version-check') + end + end +end diff --git a/spec/views/profiles/keys/_form.html.haml_spec.rb b/spec/views/profiles/keys/_form.html.haml_spec.rb index d5a605958dc..624d7492aea 100644 --- a/spec/views/profiles/keys/_form.html.haml_spec.rb +++ b/spec/views/profiles/keys/_form.html.haml_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe 'profiles/keys/_form.html.haml' do + include SshKeysHelper + let_it_be(:key) { Key.new } let(:page) { Capybara::Node::Simple.new(rendered) } @@ -23,8 +25,8 @@ RSpec.describe 'profiles/keys/_form.html.haml' do end it 'has the key field', :aggregate_failures do - expect(rendered).to have_field('Key', type: 'textarea', placeholder: 'Typically starts with "ssh-ed25519 …" or "ssh-rsa …"') - expect(rendered).to have_text("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity.") + expect(rendered).to have_field('Key', type: 'textarea') + expect(rendered).to have_text(s_('Profiles|Begins with %{ssh_key_algorithms}.') % { ssh_key_algorithms: ssh_key_allowed_algorithms }) end it 'has the title field', :aggregate_failures do diff --git a/spec/views/projects/commits/_commit.html.haml_spec.rb b/spec/views/projects/commits/_commit.html.haml_spec.rb index ed93240abc1..5c66fbe7dd7 100644 --- a/spec/views/projects/commits/_commit.html.haml_spec.rb +++ b/spec/views/projects/commits/_commit.html.haml_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'projects/commits/_commit.html.haml' do allow(commit).to receive(:different_committer?).and_return(true) allow(commit).to receive(:committer).and_return(committer) - render partial: template, locals: { + render partial: template, formats: :html, locals: { project: project, ref: ref, commit: commit diff --git a/spec/views/projects/edit.html.haml_spec.rb b/spec/views/projects/edit.html.haml_spec.rb index 8c96f286c79..11f542767f4 100644 --- a/spec/views/projects/edit.html.haml_spec.rb +++ b/spec/views/projects/edit.html.haml_spec.rb @@ -29,19 +29,6 @@ RSpec.describe 'projects/edit' do end context 'merge suggestions settings' do - it 'displays all possible variables' do - render - - expect(rendered).to have_content('%{branch_name}') - expect(rendered).to have_content('%{files_count}') - expect(rendered).to have_content('%{file_paths}') - expect(rendered).to have_content('%{project_name}') - expect(rendered).to have_content('%{project_path}') - expect(rendered).to have_content('%{user_full_name}') - expect(rendered).to have_content('%{username}') - expect(rendered).to have_content('%{suggestions_count}') - end - it 'displays a placeholder if none is set' do render @@ -58,17 +45,6 @@ RSpec.describe 'projects/edit' do end context 'merge commit template' do - it 'displays all possible variables' do - render - - expect(rendered).to have_content('%{source_branch}') - expect(rendered).to have_content('%{target_branch}') - expect(rendered).to have_content('%{title}') - expect(rendered).to have_content('%{issues}') - expect(rendered).to have_content('%{description}') - expect(rendered).to have_content('%{reference}') - end - it 'displays a placeholder if none is set' do render diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb index 6b6bc1f0b14..6ffd0936003 100644 --- a/spec/views/projects/merge_requests/show.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb @@ -45,32 +45,4 @@ RSpec.describe 'projects/merge_requests/show.html.haml', :aggregate_failures do end end end - - describe 'gitpod modal' do - let(:gitpod_modal_selector) { '#modal-enable-gitpod' } - let(:user) { create(:user) } - let(:user_gitpod_enabled) { create(:user).tap { |x| x.update!(gitpod_enabled: true) } } - - where(:site_enabled, :current_user, :should_show) do - false | ref(:user) | false - true | ref(:user) | true - true | nil | true - true | ref(:user_gitpod_enabled) | false - end - - with_them do - it 'handles rendering gitpod user enable modal' do - allow(Gitlab::CurrentSettings).to receive(:gitpod_enabled).and_return(site_enabled) - allow(view).to receive(:current_user).and_return(current_user) - - render - - if should_show - expect(rendered).to have_css(gitpod_modal_selector) - else - expect(rendered).to have_no_css(gitpod_modal_selector) - end - end - end - end end diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb index 177f703ba6c..f212fd78b1a 100644 --- a/spec/views/projects/services/_form.haml_spec.rb +++ b/spec/views/projects/services/_form.haml_spec.rb @@ -20,13 +20,33 @@ RSpec.describe 'projects/services/_form' do ) end - context 'commit_events and merge_request_events' do - it 'display merge_request_events and commit_events descriptions' do - allow(Integrations::Redmine).to receive(:supported_events).and_return(%w(commit merge_request)) - + context 'integrations form' do + it 'does not render form element' do render - expect(rendered).to have_css("input[name='redirect_to'][value='/services']", count: 1, visible: false) + expect(rendered).not_to have_selector('[data-testid="integration-form"]') + end + + context 'when vue_integration_form feature flag is disabled' do + before do + stub_feature_flags(vue_integration_form: false) + end + + it 'renders form element' do + render + + expect(rendered).to have_selector('[data-testid="integration-form"]') + end + + context 'commit_events and merge_request_events' do + it 'display merge_request_events and commit_events descriptions' do + allow(Integrations::Redmine).to receive(:supported_events).and_return(%w(commit merge_request)) + + render + + expect(rendered).to have_css("input[name='redirect_to'][value='/services']", count: 1, visible: false) + end + end end end end diff --git a/spec/views/shared/access_tokens/_table.html.haml_spec.rb b/spec/views/shared/access_tokens/_table.html.haml_spec.rb index 0a23768b4f1..fca2fc3183c 100644 --- a/spec/views/shared/access_tokens/_table.html.haml_spec.rb +++ b/spec/views/shared/access_tokens/_table.html.haml_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do let_it_be(:user) { create(:user) } let_it_be(:tokens) { [create(:personal_access_token, user: user)] } - let_it_be(:project) { false } + let_it_be(:resource) { false } before do stub_licensed_features(enforce_personal_access_token_expiration: true) @@ -20,8 +20,8 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do allow(view).to receive(:personal_access_token_expiration_enforced?).and_return(token_expiry_enforced?) allow(view).to receive(:show_profile_token_expiry_notification?).and_return(true) - if project - project.add_maintainer(user) + if resource + resource.add_maintainer(user) end # Forcibly removing scopes from one token as it's not possible to do with the current modal on creation @@ -34,7 +34,7 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do type: type, type_plural: type_plural, active_tokens: tokens, - project: project, + resource: resource, impersonation: impersonation, revoke_route_helper: ->(token) { 'path/' } } @@ -80,8 +80,8 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do end end - context 'if project' do - let_it_be(:project) { create(:project) } + context 'if resource is project' do + let_it_be(:resource) { create(:project) } it 'shows the project content', :aggregate_failures do expect(rendered).to have_selector 'th', text: 'Role' @@ -92,6 +92,18 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do end end + context 'if resource is group' do + let_it_be(:resource) { create(:group) } + + it 'shows the group content', :aggregate_failures do + expect(rendered).to have_selector 'th', text: 'Role' + expect(rendered).to have_selector 'td', text: 'Maintainer' + + expect(rendered).not_to have_content 'Personal access tokens are not revoked upon expiration.' + expect(rendered).not_to have_content 'To see all the user\'s personal access tokens you must impersonate them first.' + end + end + context 'without tokens' do let_it_be(:tokens) { [] } diff --git a/spec/views/shared/nav/_sidebar.html.haml_spec.rb b/spec/views/shared/nav/_sidebar.html.haml_spec.rb index 2eeebdff7a8..0eb945f5624 100644 --- a/spec/views/shared/nav/_sidebar.html.haml_spec.rb +++ b/spec/views/shared/nav/_sidebar.html.haml_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe 'shared/nav/_sidebar.html.haml' do - let(:project) { build(:project, id: non_existing_record_id) } + let_it_be(:project) { create(:project) } + let(:context) { Sidebars::Projects::Context.new(current_user: nil, container: project) } let(:sidebar) { Sidebars::Projects::Panel.new(context) } diff --git a/spec/views/shared/wikis/_sidebar.html.haml_spec.rb b/spec/views/shared/wikis/_sidebar.html.haml_spec.rb index 70991369506..bf050d601e3 100644 --- a/spec/views/shared/wikis/_sidebar.html.haml_spec.rb +++ b/spec/views/shared/wikis/_sidebar.html.haml_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'shared/wikis/_sidebar.html.haml' do let_it_be(:project) { create(:project) } - let_it_be(:wiki) { Wiki.for_container(project, project.default_owner) } + let_it_be(:wiki) { Wiki.for_container(project, project.first_owner) } before do assign(:wiki, wiki) diff --git a/spec/workers/ci/build_finished_worker_spec.rb b/spec/workers/ci/build_finished_worker_spec.rb index 9096b0d2ba9..839723ac2fc 100644 --- a/spec/workers/ci/build_finished_worker_spec.rb +++ b/spec/workers/ci/build_finished_worker_spec.rb @@ -50,6 +50,21 @@ RSpec.describe Ci::BuildFinishedWorker do subject end + + context 'when a build can be auto-retried' do + before do + allow(build) + .to receive(:auto_retry_allowed?) + .and_return(true) + end + + it 'does not add a todo' do + expect(::Ci::MergeRequests::AddTodoWhenBuildFailsWorker) + .not_to receive(:perform_async) + + subject + end + end end context 'when build has a chat' do diff --git a/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb b/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb new file mode 100644 index 00000000000..0460738f3f2 --- /dev/null +++ b/spec/workers/ci/job_artifacts/expire_project_build_artifacts_worker_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobArtifacts::ExpireProjectBuildArtifactsWorker do + let(:worker) { described_class.new } + let(:current_time) { Time.current } + + let_it_be(:project) { create(:project) } + + around do |example| + freeze_time { example.run } + end + + describe '#perform' do + it 'executes ExpireProjectArtifactsService service with the project' do + expect_next_instance_of(Ci::JobArtifacts::ExpireProjectBuildArtifactsService, project.id, current_time) do |instance| + expect(instance).to receive(:execute).and_call_original + end + + worker.perform(project.id) + end + + context 'when project does not exist' do + it 'does nothing' do + expect(Ci::JobArtifacts::ExpireProjectBuildArtifactsService).not_to receive(:new) + + worker.perform(non_existing_record_id) + end + end + end +end diff --git a/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb b/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb new file mode 100644 index 00000000000..1a5ca744091 --- /dev/null +++ b/spec/workers/clusters/agents/delete_expired_events_worker_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::DeleteExpiredEventsWorker do + let(:agent) { create(:cluster_agent) } + + describe '#perform' do + let(:agent_id) { agent.id } + let(:deletion_service) { double(execute: true) } + + subject { described_class.new.perform(agent_id) } + + it 'calls the deletion service' do + expect(deletion_service).to receive(:execute).once + expect(Clusters::Agents::DeleteExpiredEventsService).to receive(:new) + .with(agent).and_return(deletion_service) + + subject + end + + context 'agent no longer exists' do + let(:agent_id) { -1 } + + it 'completes without raising an error' do + expect { subject }.not_to raise_error + end + end + end +end diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb index 7608b5f49a1..85731de2a45 100644 --- a/spec/workers/concerns/application_worker_spec.rb +++ b/spec/workers/concerns/application_worker_spec.rb @@ -287,12 +287,6 @@ RSpec.describe ApplicationWorker do end context 'different kinds of push_bulk' do - shared_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' do - before do - stub_feature_flags(sidekiq_push_bulk_in_batches: false) - end - end - shared_context 'set safe limit beyond the number of jobs to be enqueued' do before do stub_const("#{described_class}::SAFE_PUSH_BULK_LIMIT", args.count + 1) @@ -408,27 +402,6 @@ RSpec.describe ApplicationWorker do it_behaves_like 'returns job_id of all enqueued jobs' it_behaves_like 'does not schedule the jobs for any specific time' end - - context 'when the feature flag `sidekiq_push_bulk_in_batches` is disabled' do - include_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' - - context 'when the number of jobs to be enqueued does not exceed the safe limit' do - include_context 'set safe limit beyond the number of jobs to be enqueued' - - it_behaves_like 'enqueues jobs in one go' - it_behaves_like 'logs bulk insertions' - it_behaves_like 'returns job_id of all enqueued jobs' - it_behaves_like 'does not schedule the jobs for any specific time' - end - - context 'when the number of jobs to be enqueued exceeds safe limit' do - include_context 'set safe limit below the number of jobs to be enqueued' - - it_behaves_like 'enqueues jobs in one go' - it_behaves_like 'returns job_id of all enqueued jobs' - it_behaves_like 'does not schedule the jobs for any specific time' - end - end end end @@ -476,26 +449,6 @@ RSpec.describe ApplicationWorker do it_behaves_like 'returns job_id of all enqueued jobs' it_behaves_like 'schedules all the jobs at a specific time' end - - context 'when the feature flag `sidekiq_push_bulk_in_batches` is disabled' do - include_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' - - context 'when the number of jobs to be enqueued does not exceed the safe limit' do - include_context 'set safe limit beyond the number of jobs to be enqueued' - - it_behaves_like 'enqueues jobs in one go' - it_behaves_like 'returns job_id of all enqueued jobs' - it_behaves_like 'schedules all the jobs at a specific time' - end - - context 'when the number of jobs to be enqueued exceeds safe limit' do - include_context 'set safe limit below the number of jobs to be enqueued' - - it_behaves_like 'enqueues jobs in one go' - it_behaves_like 'returns job_id of all enqueued jobs' - it_behaves_like 'schedules all the jobs at a specific time' - end - end end end @@ -575,26 +528,6 @@ RSpec.describe ApplicationWorker do it_behaves_like 'returns job_id of all enqueued jobs' it_behaves_like 'schedules all the jobs at a specific time, per batch' end - - context 'when the feature flag `sidekiq_push_bulk_in_batches` is disabled' do - include_context 'disable the `sidekiq_push_bulk_in_batches` feature flag' - - context 'when the number of jobs to be enqueued does not exceed the safe limit' do - include_context 'set safe limit beyond the number of jobs to be enqueued' - - it_behaves_like 'enqueues jobs in one go' - it_behaves_like 'returns job_id of all enqueued jobs' - it_behaves_like 'schedules all the jobs at a specific time, per batch' - end - - context 'when the number of jobs to be enqueued exceeds safe limit' do - include_context 'set safe limit below the number of jobs to be enqueued' - - it_behaves_like 'enqueues jobs in one go' - it_behaves_like 'returns job_id of all enqueued jobs' - it_behaves_like 'schedules all the jobs at a specific time, per batch' - end - end end end end diff --git a/spec/workers/concerns/cluster_agent_queue_spec.rb b/spec/workers/concerns/cluster_agent_queue_spec.rb new file mode 100644 index 00000000000..b5189cbd8c8 --- /dev/null +++ b/spec/workers/concerns/cluster_agent_queue_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ClusterAgentQueue do + let(:worker) do + Class.new do + def self.name + 'ExampleWorker' + end + + include ApplicationWorker + include ClusterAgentQueue + end + end + + it { expect(worker.queue).to eq('cluster_agent:example') } + it { expect(worker.get_feature_category).to eq(:kubernetes_management) } +end diff --git a/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb b/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb new file mode 100644 index 00000000000..95962d4810e --- /dev/null +++ b/spec/workers/concerns/packages/cleanup_artifact_worker_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::CleanupArtifactWorker do + let_it_be(:worker_class) do + Class.new do + def self.name + 'Gitlab::Foo::Bar::DummyWorker' + end + + include ApplicationWorker + include ::Packages::CleanupArtifactWorker + end + end + + let(:worker) { worker_class.new } + + describe '#model' do + subject { worker.send(:model) } + + it { expect { subject }.to raise_error(NotImplementedError) } + end + + describe '#log_metadata' do + subject { worker.send(:log_metadata) } + + it { expect { subject }.to raise_error(NotImplementedError) } + end + + describe '#log_cleanup_item' do + subject { worker.send(:log_cleanup_item) } + + it { expect { subject }.to raise_error(NotImplementedError) } + end +end diff --git a/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb b/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb index ed0bdefbdb8..1100f9a7fae 100644 --- a/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb +++ b/spec/workers/dependency_proxy/cleanup_dependency_proxy_worker_spec.rb @@ -9,8 +9,8 @@ RSpec.describe DependencyProxy::CleanupDependencyProxyWorker do context 'when there are records to be deleted' do it_behaves_like 'an idempotent worker' do it 'queues the cleanup jobs', :aggregate_failures do - create(:dependency_proxy_blob, :expired) - create(:dependency_proxy_manifest, :expired) + create(:dependency_proxy_blob, :pending_destruction) + create(:dependency_proxy_manifest, :pending_destruction) expect(DependencyProxy::CleanupBlobWorker).to receive(:perform_with_capacity).twice expect(DependencyProxy::CleanupManifestWorker).to receive(:perform_with_capacity).twice diff --git a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb index b035a2ec0b7..6a2fdfbe8f5 100644 --- a/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb +++ b/spec/workers/dependency_proxy/image_ttl_group_policy_worker_spec.rb @@ -17,19 +17,19 @@ RSpec.describe DependencyProxy::ImageTtlGroupPolicyWorker do let_it_be_with_reload(:new_blob) { create(:dependency_proxy_blob, group: group) } let_it_be_with_reload(:new_manifest) { create(:dependency_proxy_manifest, group: group) } - it 'updates the old images to expired' do + it 'updates the old images to pending_destruction' do expect { subject } - .to change { old_blob.reload.status }.from('default').to('expired') - .and change { old_manifest.reload.status }.from('default').to('expired') + .to change { old_blob.reload.status }.from('default').to('pending_destruction') + .and change { old_manifest.reload.status }.from('default').to('pending_destruction') .and not_change { new_blob.reload.status } .and not_change { new_manifest.reload.status } end end context 'counts logging' do - let_it_be(:expired_blob) { create(:dependency_proxy_blob, :expired, group: group) } - let_it_be(:expired_blob2) { create(:dependency_proxy_blob, :expired, group: group) } - let_it_be(:expired_manifest) { create(:dependency_proxy_manifest, :expired, group: group) } + let_it_be(:expired_blob) { create(:dependency_proxy_blob, :pending_destruction, group: group) } + let_it_be(:expired_blob2) { create(:dependency_proxy_blob, :pending_destruction, group: group) } + let_it_be(:expired_manifest) { create(:dependency_proxy_manifest, :pending_destruction, group: group) } let_it_be(:processing_blob) { create(:dependency_proxy_blob, status: :processing, group: group) } let_it_be(:processing_manifest) { create(:dependency_proxy_manifest, status: :processing, group: group) } let_it_be(:error_blob) { create(:dependency_proxy_blob, status: :error, group: group) } diff --git a/spec/workers/deployments/hooks_worker_spec.rb b/spec/workers/deployments/hooks_worker_spec.rb index b4a91cff2ac..50ead66cfbf 100644 --- a/spec/workers/deployments/hooks_worker_spec.rb +++ b/spec/workers/deployments/hooks_worker_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Deployments::HooksWorker do it 'executes project services for deployment_hooks' do deployment = create(:deployment, :running) project = deployment.project - service = create(:service, type: 'SlackService', project: project, deployment_events: true, active: true) + service = create(:integration, type: 'SlackService', project: project, deployment_events: true, active: true) expect(ProjectServiceWorker).to receive(:perform_async).with(service.id, an_instance_of(Hash)) @@ -23,7 +23,7 @@ RSpec.describe Deployments::HooksWorker do it 'does not execute an inactive service' do deployment = create(:deployment, :running) project = deployment.project - create(:service, type: 'SlackService', project: project, deployment_events: true, active: false) + create(:integration, type: 'SlackService', project: project, deployment_events: true, active: false) expect(ProjectServiceWorker).not_to receive(:perform_async) diff --git a/spec/workers/email_receiver_worker_spec.rb b/spec/workers/email_receiver_worker_spec.rb index 83720ee132b..dba535654a1 100644 --- a/spec/workers/email_receiver_worker_spec.rb +++ b/spec/workers/email_receiver_worker_spec.rb @@ -21,87 +21,45 @@ RSpec.describe EmailReceiverWorker, :mailer do context "when an error occurs" do before do allow_any_instance_of(Gitlab::Email::Receiver).to receive(:execute).and_raise(error) - expect(Sidekiq.logger).to receive(:error).with(hash_including('exception.class' => error.class.name)).and_call_original end - context 'when the error is Gitlab::Email::EmptyEmailError' do + context 'when error is a processing error' do let(:error) { Gitlab::Email::EmptyEmailError.new } - it 'sends out a rejection email' do - perform_enqueued_jobs do - described_class.new.perform(raw_message) + it 'triggers email failure handler' do + expect(Gitlab::Email::FailureHandler).to receive(:handle) do |receiver, received_error| + expect(receiver).to be_a(Gitlab::Email::Receiver) + expect(receiver.mail.encoded).to eql(Mail::Message.new(raw_message).encoded) + expect(received_error).to be(error) end - email = ActionMailer::Base.deliveries.last - expect(email).not_to be_nil - expect(email.to).to eq(["jake@adventuretime.ooo"]) - expect(email.subject).to include("Rejected") - end - - it 'strips out the body before passing to EmailRejectionMailer' do - mail = Mail.new(raw_message) - mail.body = nil - - expect(EmailRejectionMailer).to receive(:rejection).with(anything, mail.encoded, anything).and_call_original - described_class.new.perform(raw_message) end - end - - context 'when the error is Gitlab::Email::AutoGeneratedEmailError' do - let(:error) { Gitlab::Email::AutoGeneratedEmailError.new } - - it 'does not send out any rejection email' do - perform_enqueued_jobs do - described_class.new.perform(raw_message) - end - - should_not_email_anyone - end - end - context 'when the error is Gitlab::Email::InvalidAttachment' do - let(:error) { Gitlab::Email::InvalidAttachment.new("Could not deal with that") } + it 'logs the error' do + expect(Sidekiq.logger).to receive(:error).with(hash_including('exception.class' => error.class.name)).and_call_original - it 'reports the error to the sender' do - perform_enqueued_jobs do - described_class.new.perform(raw_message) - end - - email = ActionMailer::Base.deliveries.last - expect(email).not_to be_nil - expect(email.to).to eq(["jake@adventuretime.ooo"]) - expect(email.body.parts.last.to_s).to include("Could not deal with that") + described_class.new.perform(raw_message) end end - context 'when the error is ActiveRecord::StatementTimeout' do + context 'when error is not a processing error' do let(:error) { ActiveRecord::StatementTimeout.new("Statement timeout") } - it 'does not report the error to the sender' do - expect(Gitlab::ErrorTracking).to receive(:track_exception).with(error).and_call_original - - perform_enqueued_jobs do - described_class.new.perform(raw_message) + it 'triggers email failure handler' do + expect(Gitlab::Email::FailureHandler).to receive(:handle) do |receiver, received_error| + expect(receiver).to be_a(Gitlab::Email::Receiver) + expect(receiver.mail.encoded).to eql(Mail::Message.new(raw_message).encoded) + expect(received_error).to be(error) end - email = ActionMailer::Base.deliveries.last - expect(email).to be_nil + described_class.new.perform(raw_message) end - end - - context 'when the error is RateLimitedService::RateLimitedError' do - let(:error) { RateLimitedService::RateLimitedError.new(key: :issues_create, rate_limiter: Gitlab::ApplicationRateLimiter) } - it 'does not report the error to the sender' do + it 'reports the error' do expect(Gitlab::ErrorTracking).to receive(:track_exception).with(error).and_call_original - perform_enqueued_jobs do - described_class.new.perform(raw_message) - end - - email = ActionMailer::Base.deliveries.last - expect(email).to be_nil + described_class.new.perform(raw_message) end end end diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 00b6d2635a5..bb4e2981070 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -361,6 +361,7 @@ RSpec.describe 'Every Sidekiq worker' do 'ObjectPool::ScheduleJoinWorker' => 3, 'ObjectStorage::BackgroundMoveWorker' => 5, 'ObjectStorage::MigrateUploadsWorker' => 3, + 'Packages::CleanupPackageFileWorker' => 0, 'Packages::Composer::CacheUpdateWorker' => false, 'Packages::Go::SyncPackagesWorker' => 3, 'Packages::Maven::Metadata::SyncWorker' => 3, @@ -369,7 +370,7 @@ RSpec.describe 'Every Sidekiq worker' do 'PagesDomainSslRenewalWorker' => 3, 'PagesDomainVerificationWorker' => 3, 'PagesTransferWorker' => 3, - 'PagesUpdateConfigurationWorker' => 3, + 'PagesUpdateConfigurationWorker' => 1, 'PagesWorker' => 3, 'PersonalAccessTokens::Groups::PolicyWorker' => 3, 'PersonalAccessTokens::Instance::PolicyWorker' => 3, diff --git a/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb b/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb index 3c628d036ff..497f95cf34d 100644 --- a/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb +++ b/spec/workers/loose_foreign_keys/cleanup_worker_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe LooseForeignKeys::CleanupWorker do include MigrationsHelpers + using RSpec::Parameterized::TableSyntax def create_table_structure migration = ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers) @@ -149,4 +150,31 @@ RSpec.describe LooseForeignKeys::CleanupWorker do expect { described_class.new.perform }.not_to change { LooseForeignKeys::DeletedRecord.status_processed.count } end end + + describe 'multi-database support' do + where(:current_minute, :configured_base_models, :expected_connection) do + 2 | { main: ApplicationRecord, ci: Ci::ApplicationRecord } | ApplicationRecord.connection + 3 | { main: ApplicationRecord, ci: Ci::ApplicationRecord } | Ci::ApplicationRecord.connection + 2 | { main: ApplicationRecord } | ApplicationRecord.connection + 3 | { main: ApplicationRecord } | ApplicationRecord.connection + end + + with_them do + before do + allow(Gitlab::Database).to receive(:database_base_models).and_return(configured_base_models) + end + + it 'uses the correct connection' do + LooseForeignKeys::DeletedRecord.count.times do + expect_next_found_instance_of(LooseForeignKeys::DeletedRecord) do |instance| + expect(instance.class.connection).to eq(expected_connection) + end + end + + travel_to DateTime.new(2019, 1, 1, 10, current_minute) do + described_class.new.perform + end + end + end + end end diff --git a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb new file mode 100644 index 00000000000..f3ea14ad539 --- /dev/null +++ b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::UpdateHeadPipelineWorker do + include ProjectForksHelper + + let_it_be(:project) { create(:project, :repository) } + + let(:ref) { 'master' } + let(:pipeline) { create(:ci_pipeline, project: project, ref: ref) } + let(:event) { Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id }) } + + subject { consume_event(event) } + + def consume_event(event) + described_class.new.perform(event.class.name, event.data) + end + + context 'when merge requests already exist for this source branch', :sidekiq_inline do + let(:merge_request_1) do + create(:merge_request, source_branch: 'feature', target_branch: "master", source_project: project) + end + + let(:merge_request_2) do + create(:merge_request, source_branch: 'feature', target_branch: "v1.1.0", source_project: project) + end + + context 'when related merge request is already merged' do + let!(:merged_merge_request) do + create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project, state: 'merged') + end + + it 'does not schedule update head pipeline job' do + expect(UpdateHeadPipelineForMergeRequestWorker).not_to receive(:perform_async).with(merged_merge_request.id) + + subject + end + end + + context 'when the head pipeline sha equals merge request sha' do + let(:ref) { 'feature' } + + before do + pipeline.update!(sha: project.repository.commit(ref).id) + end + + it 'updates head pipeline of each merge request' do + merge_request_1 + merge_request_2 + + subject + + expect(merge_request_1.reload.head_pipeline).to eq(pipeline) + expect(merge_request_2.reload.head_pipeline).to eq(pipeline) + end + end + + context 'when the head pipeline sha does not equal merge request sha' do + let(:ref) { 'feature' } + + it 'does not update the head piepeline of MRs' do + merge_request_1 + merge_request_2 + + subject + + expect(merge_request_1.reload.head_pipeline).not_to eq(pipeline) + expect(merge_request_2.reload.head_pipeline).not_to eq(pipeline) + end + end + + context 'when there is no pipeline for source branch' do + it "does not update merge request head pipeline" do + merge_request = create(:merge_request, source_branch: 'feature', + target_branch: "branch_1", + source_project: project) + + subject + + expect(merge_request.reload.head_pipeline).not_to eq(pipeline) + end + end + + context 'when merge request target project is different from source project' do + let(:project) { fork_project(target_project, nil, repository: true) } + let(:target_project) { create(:project, :repository) } + let(:user) { create(:user) } + let(:ref) { 'feature' } + + before do + project.add_developer(user) + pipeline.update!(sha: project.repository.commit(ref).id) + end + + it 'updates head pipeline for merge request' do + merge_request = create(:merge_request, source_branch: 'feature', + target_branch: "master", + source_project: project, + target_project: target_project) + + subject + + expect(merge_request.reload.head_pipeline).to eq(pipeline) + end + end + + context 'when the pipeline is not the latest for the branch' do + it 'does not update merge request head pipeline' do + merge_request = create(:merge_request, source_branch: 'master', + target_branch: "branch_1", + source_project: project) + + create(:ci_pipeline, project: pipeline.project, ref: pipeline.ref) + + subject + + expect(merge_request.reload.head_pipeline).to be_nil + end + end + + context 'when pipeline has errors' do + before do + pipeline.update!(yaml_errors: 'some errors', status: :failed) + end + + it 'updates merge request head pipeline reference' do + merge_request = create(:merge_request, source_branch: 'master', + target_branch: 'feature', + source_project: project) + + subject + + expect(merge_request.reload.head_pipeline).to eq(pipeline) + end + end + end +end diff --git a/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb b/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb index 19b79835825..f151780ffd7 100644 --- a/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb +++ b/spec/workers/metrics/dashboard/sync_dashboards_worker_spec.rb @@ -10,16 +10,34 @@ RSpec.describe Metrics::Dashboard::SyncDashboardsWorker do let(:dashboard_path) { '.gitlab/dashboards/test.yml' } describe ".perform" do - it 'imports metrics' do - expect { worker.perform(project.id) }.to change(PrometheusMetric, :count).by(3) + context 'with valid dashboard hash' do + it 'imports metrics' do + expect { worker.perform(project.id) }.to change(PrometheusMetric, :count).by(3) + end + + it 'is idempotent' do + 2.times do + worker.perform(project.id) + end + + expect(PrometheusMetric.count).to eq(3) + end end - it 'is idempotent' do - 2.times do - worker.perform(project.id) + context 'with invalid dashboard hash' do + before do + allow_next_instance_of(Gitlab::Metrics::Dashboard::Importer) do |instance| + allow(instance).to receive(:dashboard_hash).and_return({}) + end end - expect(PrometheusMetric.count).to eq(3) + it 'does not import metrics' do + expect { worker.perform(project.id) }.not_to change(PrometheusMetric, :count) + end + + it 'does not raise an error' do + expect { worker.perform(project.id) }.not_to raise_error + end end end end diff --git a/spec/workers/packages/cleanup_package_file_worker_spec.rb b/spec/workers/packages/cleanup_package_file_worker_spec.rb new file mode 100644 index 00000000000..b423c4d3f06 --- /dev/null +++ b/spec/workers/packages/cleanup_package_file_worker_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::CleanupPackageFileWorker do + let_it_be(:package) { create(:package) } + + let(:worker) { described_class.new } + + describe '#perform_work' do + subject { worker.perform_work } + + context 'with no work to do' do + it { is_expected.to be_nil } + end + + context 'with work to do' do + let_it_be(:package_file1) { create(:package_file, package: package) } + let_it_be(:package_file2) { create(:package_file, :pending_destruction, package: package) } + let_it_be(:package_file3) { create(:package_file, :pending_destruction, package: package, updated_at: 1.year.ago, created_at: 1.year.ago) } + + it 'deletes the oldest package file pending destruction based on id', :aggregate_failures do + expect(worker).to receive(:log_extra_metadata_on_done).twice + + expect { subject }.to change { Packages::PackageFile.count }.by(-1) + end + end + + context 'with an error during the destroy' do + let_it_be(:package_file) { create(:package_file, :pending_destruction) } + + before do + expect(worker).to receive(:log_metadata).and_raise('Error!') + end + + it 'handles the error' do + expect { subject }.to change { Packages::PackageFile.error.count }.from(0).to(1) + expect(package_file.reload).to be_error + end + end + end + + describe '#max_running_jobs' do + let(:capacity) { 5 } + + subject { worker.max_running_jobs } + + before do + stub_application_setting(packages_cleanup_package_file_worker_capacity: capacity) + end + + it { is_expected.to eq(capacity) } + end + + describe '#remaining_work_count' do + before(:context) do + create_list(:package_file, 3, :pending_destruction, package: package) + end + + subject { worker.remaining_work_count } + + it { is_expected.to eq(3) } + end +end diff --git a/spec/workers/packages/cleanup_package_registry_worker_spec.rb b/spec/workers/packages/cleanup_package_registry_worker_spec.rb new file mode 100644 index 00000000000..e43864975f6 --- /dev/null +++ b/spec/workers/packages/cleanup_package_registry_worker_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::CleanupPackageRegistryWorker do + describe '#perform' do + let_it_be_with_reload(:package_files) { create_list(:package_file, 2, :pending_destruction) } + + let(:worker) { described_class.new } + + subject(:perform) { worker.perform } + + context 'with package files pending destruction' do + it_behaves_like 'an idempotent worker' + + it 'queues the cleanup job' do + expect(Packages::CleanupPackageFileWorker).to receive(:perform_with_capacity) + + perform + end + end + + context 'with no package files pending destruction' do + before do + ::Packages::PackageFile.update_all(status: :default) + end + + it_behaves_like 'an idempotent worker' + + it 'does not queue the cleanup job' do + expect(Packages::CleanupPackageFileWorker).not_to receive(:perform_with_capacity) + + perform + end + end + + describe 'counts logging' do + let_it_be(:processing_package_file) { create(:package_file, status: :processing) } + + it 'logs all the counts', :aggregate_failures do + expect(worker).to receive(:log_extra_metadata_on_done).with(:pending_destruction_package_files_count, 2) + expect(worker).to receive(:log_extra_metadata_on_done).with(:processing_package_files_count, 1) + expect(worker).to receive(:log_extra_metadata_on_done).with(:error_package_files_count, 0) + + perform + end + + context 'with load balancing enabled', :db_load_balancing do + it 'reads the count from the replica' do + expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original + + perform + end + end + end + end +end diff --git a/spec/workers/pages_update_configuration_worker_spec.rb b/spec/workers/pages_update_configuration_worker_spec.rb index 7cceeaa52d6..af71f6b3cca 100644 --- a/spec/workers/pages_update_configuration_worker_spec.rb +++ b/spec/workers/pages_update_configuration_worker_spec.rb @@ -5,59 +5,8 @@ RSpec.describe PagesUpdateConfigurationWorker do let_it_be(:project) { create(:project) } describe "#perform" do - it "does not break if the project doesn't exist" do + it "does not break" do expect { subject.perform(-1) }.not_to raise_error end - - it "calls the correct service" do - expect_next_instance_of(Projects::UpdatePagesConfigurationService, project) do |service| - expect(service).to receive(:execute).and_return({}) - end - - subject.perform(project.id) - end - - it_behaves_like "an idempotent worker" do - let(:job_args) { [project.id] } - let(:pages_dir) { Dir.mktmpdir } - let(:config_path) { File.join(pages_dir, "config.json") } - - before do - allow(Project).to receive(:find_by_id).with(project.id).and_return(project) - allow(project).to receive(:pages_path).and_return(pages_dir) - - # Make sure _some_ config exists - FileUtils.touch(config_path) - end - - after do - FileUtils.remove_entry(pages_dir) - end - - it "only updates the config file once" do - described_class.new.perform(project.id) - - expect(File.mtime(config_path)).not_to be_nil - expect { subject }.not_to change { File.mtime(config_path) } - end - end - end - - describe '#perform_async' do - it "calls the correct service", :sidekiq_inline do - expect_next_instance_of(Projects::UpdatePagesConfigurationService, project) do |service| - expect(service).to receive(:execute).and_return(status: :success) - end - - described_class.perform_async(project.id) - end - - it "doesn't schedule a worker if updates on legacy storage are disabled", :sidekiq_inline do - allow(Settings.pages.local_store).to receive(:enabled).and_return(false) - - expect(Projects::UpdatePagesConfigurationService).not_to receive(:new) - - described_class.perform_async(project.id) - end end end diff --git a/spec/workers/pages_worker_spec.rb b/spec/workers/pages_worker_spec.rb new file mode 100644 index 00000000000..5ddfd5b43b9 --- /dev/null +++ b/spec/workers/pages_worker_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe PagesWorker, :sidekiq_inline do + let(:project) { create(:project) } + let(:ci_build) { create(:ci_build, project: project)} + + it 'calls UpdatePagesService' do + expect_next_instance_of(Projects::UpdatePagesService, project, ci_build) do |service| + expect(service).to receive(:execute) + end + + described_class.perform_async(:deploy, ci_build.id) + end +end diff --git a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb index b928104fb58..3de59670f8d 100644 --- a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb +++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb @@ -25,11 +25,11 @@ RSpec.describe PurgeDependencyProxyCacheWorker do include_examples 'an idempotent worker' do let(:job_args) { [user.id, group_id] } - it 'expires the blobs and returns ok', :aggregate_failures do + it 'marks the blobs as pending_destruction and returns ok', :aggregate_failures do subject - expect(blob).to be_expired - expect(manifest).to be_expired + expect(blob).to be_pending_destruction + expect(manifest).to be_pending_destruction end end end diff --git a/spec/workers/web_hook_worker_spec.rb b/spec/workers/web_hook_worker_spec.rb index 0f40177eb7d..bbb8844a447 100644 --- a/spec/workers/web_hook_worker_spec.rb +++ b/spec/workers/web_hook_worker_spec.rb @@ -19,6 +19,15 @@ RSpec.describe WebHookWorker do expect { subject.perform(non_existing_record_id, data, hook_name) }.not_to raise_error end + it 'retrieves recursion detection data, reinstates it, and cleans it from payload', :request_store, :aggregate_failures do + uuid = SecureRandom.uuid + full_data = data.merge({ _gitlab_recursion_detection_request_uuid: uuid }) + + expect_next(WebHookService, project_hook, data.with_indifferent_access, hook_name, anything).to receive(:execute) + expect { subject.perform(project_hook.id, full_data, hook_name) } + .to change { Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid }.to(uuid) + end + it_behaves_like 'worker with data consistency', described_class, data_consistency: :delayed |