diff options
Diffstat (limited to 'spec/support/shared_examples')
69 files changed, 2490 insertions, 592 deletions
diff --git a/spec/support/shared_examples/ci/jobs_shared_examples.rb b/spec/support/shared_examples/ci/jobs_shared_examples.rb new file mode 100644 index 00000000000..d952d4a98eb --- /dev/null +++ b/spec/support/shared_examples/ci/jobs_shared_examples.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a job with artifacts and trace' do |result_is_array: true| + context 'with artifacts and trace' do + let!(:second_job) { create(:ci_build, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) } + + it 'returns artifacts and trace data', :skip_before_request do + get api(api_endpoint, api_user) + json_job = json_response.is_a?(Array) ? json_response.find { |job| job['id'] == second_job.id } : json_response + + expect(json_job['artifacts_file']).not_to be_nil + expect(json_job['artifacts_file']).not_to be_empty + expect(json_job['artifacts_file']['filename']).to eq(second_job.artifacts_file.filename) + expect(json_job['artifacts_file']['size']).to eq(second_job.artifacts_file.size) + expect(json_job['artifacts']).not_to be_nil + expect(json_job['artifacts']).to be_an Array + expect(json_job['artifacts'].size).to eq(second_job.job_artifacts.length) + json_job['artifacts'].each do |artifact| + expect(artifact).not_to be_nil + file_type = Ci::JobArtifact.file_types[artifact['file_type']] + expect(artifact['size']).to eq(second_job.job_artifacts.find_by(file_type: file_type).size) + expect(artifact['filename']).to eq(second_job.job_artifacts.find_by(file_type: file_type).filename) + expect(artifact['file_format']).to eq(second_job.job_artifacts.find_by(file_type: file_type).file_format) + end + end + end +end diff --git a/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb b/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb deleted file mode 100644 index acce7642cfe..00000000000 --- a/spec/support/shared_examples/controllers/binary_blob_shared_examples.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'editing snippet checks blob is binary' do - let(:snippets_binary_blob_value) { true } - - before do - sign_in(user) - - allow_next_instance_of(Blob) do |blob| - allow(blob).to receive(:binary?).and_return(binary) - end - - stub_feature_flags(snippets_binary_blob: snippets_binary_blob_value) - - subject - end - - context 'when blob is text' do - let(:binary) { false } - - it 'responds with status 200' do - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:edit) - end - end - - context 'when blob is binary' do - let(:binary) { true } - - it 'responds with status 200' do - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:edit) - end - - context 'when feature flag :snippets_binary_blob is disabled' do - let(:snippets_binary_blob_value) { false } - - it 'redirects away' do - expect(response).to redirect_to(gitlab_snippet_path(snippet)) - end - end - end -end - -RSpec.shared_examples 'updating snippet checks blob is binary' do - let(:snippets_binary_blob_value) { true } - - before do - sign_in(user) - - allow_next_instance_of(Blob) do |blob| - allow(blob).to receive(:binary?).and_return(binary) - end - - stub_feature_flags(snippets_binary_blob: snippets_binary_blob_value) - - subject - end - - context 'when blob is text' do - let(:binary) { false } - - it 'updates successfully' do - expect(snippet.reload.title).to eq title - expect(response).to redirect_to(gitlab_snippet_path(snippet)) - end - end - - context 'when blob is binary' do - let(:binary) { true } - - it 'updates successfully' do - expect(snippet.reload.title).to eq title - expect(response).to redirect_to(gitlab_snippet_path(snippet)) - end - - context 'when feature flag :snippets_binary_blob is disabled' do - let(:snippets_binary_blob_value) { false } - - it 'redirects away without updating' do - expect(response).to redirect_to(gitlab_snippet_path(snippet)) - expect(snippet.reload.title).not_to eq title - end - end - end -end diff --git a/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb b/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb deleted file mode 100644 index 17087456720..00000000000 --- a/spec/support/shared_examples/controllers/instance_statistics_controllers_shared_examples.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'instance statistics availability' do - let(:user) { create(:user) } - - before do - sign_in(user) - - stub_application_setting(usage_ping_enabled: true) - end - - describe 'GET #index' do - it 'is available when the feature is available publicly' do - get :index - - expect(response).to have_gitlab_http_status(:success) - end - - it 'renders a 404 when the feature is not available publicly' do - stub_application_setting(instance_statistics_visibility_private: true) - - get :index - - expect(response).to have_gitlab_http_status(:not_found) - end - - context 'for admins' do - let(:user) { create(:admin) } - - context 'when admin mode disabled' do - it 'forbids access when the feature is not available publicly' do - stub_application_setting(instance_statistics_visibility_private: true) - - get :index - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when admin mode enabled', :enable_admin_mode do - it 'allows access when the feature is not available publicly' do - stub_application_setting(instance_statistics_visibility_private: true) - - get :index - - expect(response).to have_gitlab_http_status(:success) - end - end - end - end -end diff --git a/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb b/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb index 62a1a07b6c1..02915206cc5 100644 --- a/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb +++ b/spec/support/shared_examples/controllers/issuables_list_metadata_shared_examples.rb @@ -42,10 +42,6 @@ RSpec.shared_examples 'issuables list meta-data' do |issuable_type, action = nil let(:result_issuable) { issuables.first } let(:search) { result_issuable.title } - before do - stub_feature_flags(attempt_project_search_optimizations: true) - end - # .simple_sorts is the same across all Sortable classes sorts = ::Issue.simple_sorts.keys + %w[popularity priority label_priority] sorts.each do |sort| diff --git a/spec/support/shared_examples/controllers/unique_hll_events_examples.rb b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb new file mode 100644 index 00000000000..7e5a225f020 --- /dev/null +++ b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'tracking unique hll events' do |feature_flag| + context 'when format is HTML' do + let(:format) { :html } + + it 'tracks unique event' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(expected_type, target_id) + + subject + end + + it 'tracks unique event if DNT is not enabled' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(expected_type, target_id) + request.headers['DNT'] = '0' + + subject + end + + it 'does not track unique event if DNT is enabled' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with(expected_type, target_id) + request.headers['DNT'] = '1' + + subject + end + + context 'when feature flag is disabled' do + it 'does not track unique event' do + stub_feature_flags(feature_flag => false) + + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with(expected_type, target_id) + + subject + end + end + end + + context 'when format is JSON' do + let(:format) { :json } + + it 'does not track unique event if the format is JSON' do + expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event).with(expected_type, target_id) + + subject + end + end +end diff --git a/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb b/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb index 90588756eb0..428389a9a01 100644 --- a/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb +++ b/spec/support/shared_examples/controllers/unique_visits_shared_examples.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true RSpec.shared_examples 'tracking unique visits' do |method| + let(:request_params) { {} } + it 'tracks unique visit if the format is HTML' do expect_any_instance_of(Gitlab::Analytics::UniqueVisits).to receive(:track_visit).with(instance_of(String), target_id) diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb index c89ee0d25ae..4ca400dd87b 100644 --- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb +++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb @@ -388,7 +388,54 @@ RSpec.shared_examples 'wiki controller actions' do end.not_to change { wiki.list_pages.size } expect(response).to render_template('shared/wikis/edit') - expect(flash[:alert]).to eq('Could not create wiki page') + end + end + end + + describe 'DELETE #destroy' do + let(:id_param) { wiki_title } + + subject do + delete(:destroy, + params: routing_params.merge( + id: id_param + )) + end + + context 'when page exists' do + it 'deletes the page' do + expect do + subject + end.to change { wiki.list_pages.size }.by(-1) + end + + context 'but page cannot be deleted' do + before do + allow_next_instance_of(WikiPage) do |page| + allow(page).to receive(:delete).and_return(false) + end + end + + it 'renders the edit state' do + expect do + subject + end.not_to change { wiki.list_pages.size } + + expect(response).to render_template('shared/wikis/edit') + expect(assigns(:error).message).to eq('Could not delete wiki page') + end + end + end + + context 'when page does not exist' do + let(:id_param) { 'nil' } + + it 'renders 404' do + expect do + subject + end.not_to change { wiki.list_pages.size } + + expect(response).to have_gitlab_http_status(:not_found) end end end diff --git a/spec/support/shared_examples/features/2fa_shared_examples.rb b/spec/support/shared_examples/features/2fa_shared_examples.rb new file mode 100644 index 00000000000..ddc03e178ba --- /dev/null +++ b/spec/support/shared_examples/features/2fa_shared_examples.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'hardware device for 2fa' do |device_type| + include Spec::Support::Helpers::Features::TwoFactorHelpers + + def register_device(device_type, **kwargs) + case device_type.downcase + when "u2f" + register_u2f_device(**kwargs) + when "webauthn" + register_webauthn_device(**kwargs) + else + raise "Unknown device type #{device_type}" + end + end + + describe "registration" do + let(:user) { create(:user) } + + before do + gitlab_sign_in(user) + user.update_attribute(:otp_required_for_login, true) + end + + describe 'when 2FA via OTP is disabled' do + before do + user.update_attribute(:otp_required_for_login, false) + end + + it 'does not allow registering a new device' do + visit profile_account_path + click_on 'Enable two-factor authentication' + + expect(page).to have_button("Set up new device", disabled: true) + end + end + + describe 'when 2FA via OTP is enabled' do + it 'allows registering a new device with a name' do + visit profile_account_path + manage_two_factor_authentication + expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators") + + device = register_device(device_type) + + expect(page).to have_content(device.name) + expect(page).to have_content("Your #{device_type} device was registered") + end + + it 'allows deleting a device' do + visit profile_account_path + manage_two_factor_authentication + expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators") + + first_device = register_device(device_type) + second_device = register_device(device_type, name: 'My other device') + + expect(page).to have_content(first_device.name) + expect(page).to have_content(second_device.name) + + accept_confirm { click_on 'Delete', match: :first } + + expect(page).to have_content('Successfully deleted') + expect(page.body).not_to have_content(first_device.name) + expect(page.body).to have_content(second_device.name) + end + end + end + + describe 'fallback code authentication' do + let(:user) { create(:user) } + + before do + # Register and logout + gitlab_sign_in(user) + user.update_attribute(:otp_required_for_login, true) + visit profile_account_path + end + + describe 'when no device is registered' do + before do + gitlab_sign_out + gitlab_sign_in(user) + end + + it 'shows the fallback otp code UI' do + assert_fallback_ui(page) + end + end + + describe 'when a device is registered' do + before do + manage_two_factor_authentication + register_device(device_type) + gitlab_sign_out + gitlab_sign_in(user) + end + + it 'provides a button that shows the fallback otp code UI' do + expect(page).to have_link('Sign in via 2FA code') + + click_link('Sign in via 2FA code') + + assert_fallback_ui(page) + end + end + end +end diff --git a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb index 487c38da7da..c9910487798 100644 --- a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb +++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb @@ -124,3 +124,16 @@ end def get_textarea_height page.evaluate_script('document.getElementById("merge_request_description").offsetHeight') end + +RSpec.shared_examples 'an editable merge request with reviewers' do + it 'updates merge request', :js do + find('.js-reviewer-search').click + page.within '.dropdown-menu-user' do + click_link user.name + end + expect(find('input[name="merge_request[reviewer_ids][]"]', visible: false).value).to match(user.id.to_s) + page.within '.js-reviewer-search' do + expect(page).to have_content user.name + end + end +end diff --git a/spec/support/shared_examples/features/error_tracking_shared_example.rb b/spec/support/shared_examples/features/error_tracking_shared_example.rb index ae7d62f31a2..92fc54ce0b0 100644 --- a/spec/support/shared_examples/features/error_tracking_shared_example.rb +++ b/spec/support/shared_examples/features/error_tracking_shared_example.rb @@ -36,10 +36,10 @@ end RSpec.shared_examples 'expanded stack trace context' do |selected_line: nil, expected_line: 1| it 'expands the stack trace context', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/217810' } do within('div.stacktrace') do - find("div.file-holder:nth-child(#{selected_line}) svg.ic-chevron-right").click if selected_line + find("div.file-holder:nth-child(#{selected_line}) svg[data-testid='chevron-right-icon']").click if selected_line expanded_line = find("div.file-holder:nth-child(#{expected_line})") - expect(expanded_line).to have_css('svg.ic-chevron-down') + expect(expanded_line).to have_css('svg[data-testid="chevron-down-icon"]') event_response['entries'][0]['data']['values'][0]['stacktrace']['frames'][-expected_line]['context'].each do |context| expect(page).to have_content(context[0]) diff --git a/spec/support/shared_examples/features/file_uploads_shared_examples.rb b/spec/support/shared_examples/features/file_uploads_shared_examples.rb new file mode 100644 index 00000000000..ea8c8d44501 --- /dev/null +++ b/spec/support/shared_examples/features/file_uploads_shared_examples.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handling file uploads' do |shared_examples_name| + context 'with object storage disabled' do + context 'with upload_middleware_jwt_params_handler disabled' do + before do + stub_feature_flags(upload_middleware_jwt_params_handler: false) + + expect_next_instance_of(Gitlab::Middleware::Multipart::Handler) do |handler| + expect(handler).to receive(:with_open_files).and_call_original + end + end + + it_behaves_like shared_examples_name + end + + context 'with upload_middleware_jwt_params_handler enabled' do + before do + stub_feature_flags(upload_middleware_jwt_params_handler: true) + + expect_next_instance_of(Gitlab::Middleware::Multipart::HandlerForJWTParams) do |handler| + expect(handler).to receive(:with_open_files).and_call_original + end + end + + it_behaves_like shared_examples_name + 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 6debbf81fc0..f201421e827 100644 --- a/spec/support/shared_examples/features/packages_shared_examples.rb +++ b/spec/support/shared_examples/features/packages_shared_examples.rb @@ -14,7 +14,7 @@ RSpec.shared_examples 'packages list' do |check_project_name: false| end def package_table_row(index) - page.all("#{packages_table_selector} > [data-qa-selector=\"packages-row\"]")[index].text + page.all("#{packages_table_selector} > [data-qa-selector=\"package_row\"]")[index].text end end @@ -32,7 +32,7 @@ RSpec.shared_examples 'package details link' do |property| expect(page).to have_current_path(project_package_path(package.project, package)) - page.within('.detail-page-header') do + page.within('[data-qa-selector="package_title"]') do expect(page).to have_content(package.name) end diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb index a58e716efd2..3a046c3feec 100644 --- a/spec/support/shared_examples/graphql/members_shared_examples.rb +++ b/spec/support/shared_examples/graphql/members_shared_examples.rb @@ -20,3 +20,64 @@ RSpec.shared_examples 'a working membership object query' do |model_option| ).to eq('DEVELOPER') end end + +RSpec.shared_examples 'querying members with a group' do + let_it_be(:root_group) { create(:group, :private) } + let_it_be(:group_1) { create(:group, :private, parent: root_group, name: 'Main Group') } + let_it_be(:group_2) { create(:group, :private, parent: root_group) } + + let_it_be(:user_1) { create(:user, name: 'test user') } + let_it_be(:user_2) { create(:user, name: 'test user 2') } + let_it_be(:user_3) { create(:user, name: 'another user 1') } + let_it_be(:user_4) { create(:user, name: 'another user 2') } + + let_it_be(:root_group_member) { create(:group_member, user: user_4, group: root_group) } + let_it_be(:group_1_member) { create(:group_member, user: user_2, group: group_1) } + let_it_be(:group_2_member) { create(:group_member, user: user_3, group: group_2) } + + let(:args) { {} } + + subject do + resolve(described_class, obj: resource, args: args, ctx: { current_user: user_4 }) + end + + describe '#resolve' do + before do + group_1.add_maintainer(user_4) + end + + it 'finds all resource members' do + expect(subject).to contain_exactly(resource_member, group_1_member, root_group_member) + end + + context 'with search' do + context 'when the search term matches a user' do + let(:args) { { search: 'test' } } + + it 'searches users by user name' do + expect(subject).to contain_exactly(resource_member, group_1_member) + end + end + + context 'when the search term does not match any user' do + let(:args) { { search: 'nothing' } } + + it 'is empty' do + expect(subject).to be_empty + end + end + end + + context 'when user can not see resource members' do + let_it_be(:other_user) { create(:user) } + + subject do + resolve(described_class, obj: resource, args: args, ctx: { current_user: other_user }) + end + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + end +end diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb index 86d2bb6c747..b67cac94547 100644 --- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb @@ -19,6 +19,13 @@ RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []| end end +# There must be a method or let called `mutation` defined that executes +# the mutation. +RSpec.shared_examples 'a mutation that returns a top-level access error' do + include_examples 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] +end + RSpec.shared_examples 'an invalid argument to the mutation' do |argument_name:| it_behaves_like 'a mutation that returns top-level errors' do let(:match_errors) do diff --git a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb index 2ef71d275a2..7627a7b4d59 100644 --- a/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb +++ b/spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb @@ -84,6 +84,9 @@ RSpec.shared_examples 'sorted paginated query' do cursored_query = pagination_query([sort_argument, "after: \"#{end_cursor}\""].compact.join(','), page_info) post_graphql(cursored_query, current_user: current_user) + + expect(response).to have_gitlab_http_status(:ok) + response_data = graphql_dig_at(Gitlab::Json.parse(response.body), :data, *data_path, :edges) expect(pagination_results_data(response_data)).to eq expected_results.drop(first_param) diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb new file mode 100644 index 00000000000..ed139e638bf --- /dev/null +++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'Gitlab-style deprecations' do + describe 'validations' do + it 'raises an informative error if `deprecation_reason` is used' do + expect { subject(deprecation_reason: 'foo') }.to raise_error( + ArgumentError, + 'Use `deprecated` property instead of `deprecation_reason`. ' \ + 'See https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#deprecating-fields-and-enum-values' + ) + end + + it 'raises an error if a required property is missing', :aggregate_failures do + expect { subject(deprecated: { milestone: '1.10' }) }.to raise_error( + ArgumentError, + 'Please provide a `reason` within `deprecated`' + ) + expect { subject(deprecated: { reason: 'Deprecation reason' }) }.to raise_error( + ArgumentError, + 'Please provide a `milestone` within `deprecated`' + ) + end + + it 'raises an error if milestone is not a String', :aggregate_failures do + expect { subject(deprecated: { milestone: 1.10, reason: 'Deprecation reason' }) }.to raise_error( + ArgumentError, + '`milestone` must be a `String`' + ) + end + end + + it 'adds a formatted `deprecated_reason` to the subject' do + deprecable = subject(deprecated: { milestone: '1.10', reason: 'Deprecation reason' }) + + expect(deprecable.deprecation_reason).to eq('Deprecation reason. Deprecated in 1.10') + end + + it 'appends to the description if given' do + deprecable = subject( + deprecated: { milestone: '1.10', reason: 'Deprecation reason' }, + description: 'Deprecable description' + ) + + expect(deprecable.description).to eq('Deprecable description. Deprecated in 1.10: Deprecation reason') + end + + it 'does not append to the description if it is absent' do + deprecable = subject(deprecated: { milestone: '1.10', reason: 'Deprecation reason' }) + + expect(deprecable.description).to be_nil + end +end diff --git a/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb b/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb index d903c0f10e0..479b26977e2 100644 --- a/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb +++ b/spec/support/shared_examples/lib/banzai/reference_parser_shared_examples.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples "referenced feature visibility" do |*related_features| + let(:enable_user?) { false } let(:feature_fields) do related_features.map { |feature| (feature + "_access_level").to_sym } end @@ -35,8 +36,11 @@ RSpec.shared_examples "referenced feature visibility" do |*related_features| end context "when feature is enabled" do - # The project is public + # Allows implementing specs to enable finer-tuned permissions + let(:enable_user?) { true } + it "creates reference" do + # The project is public set_features_fields_to(ProjectFeature::ENABLED) expect(subject.nodes_visible_to_user(user, [link])).to eq([link]) diff --git a/spec/support/shared_examples/lib/gitlab/alert_management/payload.rb b/spec/support/shared_examples/lib/gitlab/alert_management/payload.rb new file mode 100644 index 00000000000..54b021e8371 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/alert_management/payload.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'parsable alert payload field with fallback' do |fallback, *paths| + context 'without payload' do + it { is_expected.to eq(fallback) } + end + + paths.each do |path| + context "with #{path}" do + let(:value) { 'some value' } + + before do + section, name = path.split('/') + raw_payload[section] = name ? { name => value } : value + end + + it { is_expected.to eq(value) } + end + end +end + +RSpec.shared_examples 'parsable alert payload field' do |*paths| + it_behaves_like 'parsable alert payload field with fallback', nil, *paths +end + +RSpec.shared_examples 'subclass has expected api' do + it 'defines all public methods in the base class' do + default_methods = Gitlab::AlertManagement::Payload::Base.public_instance_methods + subclass_methods = described_class.public_instance_methods + missing_methods = subclass_methods - default_methods + + expect(missing_methods).to be_empty + end +end diff --git a/spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb new file mode 100644 index 00000000000..18a5087da3b --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/auth/atlassian_identity_shared_examples.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'an atlassian identity' do + it 'sets the proper values' do + expect(identity.extern_uid).to eq(extern_uid) + expect(identity.token).to eq(credentials[:token]) + expect(identity.refresh_token).to eq(credentials[:refresh_token]) + expect(identity.expires_at.to_i).to eq(credentials[:expires_at]) + end +end diff --git a/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb index 8cf6babe146..e93077c42e1 100644 --- a/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/background_migration/mentions_migration_shared_examples.rb @@ -63,7 +63,7 @@ RSpec.shared_examples 'schedules resource mentions migration' do |resource_class it 'schedules background migrations' do Sidekiq::Testing.fake! do - Timecop.freeze do + freeze_time do resource_count = is_for_notes ? Note.count : resource_class.count expect(resource_count).to eq 5 diff --git a/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb index a3800f050bb..f018ece0d46 100644 --- a/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/kubernetes/network_policy_common_shared_examples.rb @@ -5,18 +5,19 @@ RSpec.shared_examples 'network policy common specs' do let(:namespace) { 'example-namespace' } let(:labels) { nil } + describe '#generate' do + subject { policy.generate } + + it { is_expected.to eq(Kubeclient::Resource.new(policy.resource)) } + end + describe 'as_json' do let(:json_policy) do { name: name, namespace: namespace, creation_timestamp: nil, - manifest: YAML.dump( - { - metadata: metadata, - spec: spec - }.deep_stringify_keys - ), + manifest: YAML.dump(policy.resource.deep_stringify_keys), is_autodevops: false, is_enabled: true } diff --git a/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb new file mode 100644 index 00000000000..6327367fcc2 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/middleware/multipart_shared_examples.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handling all upload parameters conditions' do + context 'one root parameter' do + include_context 'with one temporary file for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) } + let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id) } + + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file)) + + subject + end + end + + context 'two root parameters' do + include_context 'with two temporary files for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('file1' => uploaded_filepath, 'file2' => uploaded_filepath2) } + let(:params) do + upload_parameters_for(filepath: uploaded_filepath, key: 'file1', filename: filename, remote_id: remote_id).merge( + upload_parameters_for(filepath: uploaded_filepath2, key: 'file2', filename: filename2, remote_id: remote_id2) + ) + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file1) }, + { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(file2) } + ]) + + subject + end + end + + context 'one nested parameter' do + include_context 'with one temporary file for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath) } + let(:params) { { 'user' => { 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } } + + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar)) + + subject + end + end + + context 'two nested parameters' do + include_context 'with two temporary files for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath, 'user[screenshot]' => uploaded_filepath2) } + let(:params) do + { + 'user' => { + 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id), + 'screenshot' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2) + } + } + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar) }, + { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user screenshot) } + ]) + + subject + end + end + + context 'one deeply nested parameter' do + include_context 'with one temporary file for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath) } + let(:params) { { 'user' => { 'avatar' => { 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } } } + + it 'builds an UploadedFile' do + expect_uploaded_files(filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas)) + + subject + end + end + + context 'two deeply nested parameters' do + include_context 'with two temporary files for multipart' + + let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath, 'user[friend][ananas]' => uploaded_filepath2) } + let(:params) do + { + 'user' => { + 'avatar' => { + 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) + }, + 'friend' => { + 'ananas' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2) + } + } + } + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas) }, + { filepath: uploaded_file2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user friend ananas) } + ]) + + subject + end + end + + context 'three parameters nested at different levels' do + include_context 'with three temporary files for multipart' + + let(:rewritten_fields) do + rewritten_fields_hash( + 'file' => uploaded_filepath, + 'user[avatar]' => uploaded_filepath2, + 'user[friend][avatar]' => uploaded_filepath3 + ) + end + + let(:params) do + upload_parameters_for(filepath: uploaded_filepath, filename: filename, key: 'file', remote_id: remote_id).merge( + 'user' => { + 'avatar' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2), + 'friend' => { + 'avatar' => upload_parameters_for(filepath: uploaded_filepath3, filename: filename3, remote_id: remote_id3) + } + } + ) + end + + it 'builds UploadedFiles' do + expect_uploaded_files([ + { filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file) }, + { filepath: uploaded_filepath2, original_filename: filename2, remote_id: remote_id2, size: uploaded_file2.size, params_path: %w(user avatar) }, + { filepath: uploaded_filepath3, original_filename: filename3, remote_id: remote_id3, size: uploaded_file3.size, params_path: %w(user friend avatar) } + ]) + + subject + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb new file mode 100644 index 00000000000..94ef41ce5a5 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/project_search_results_shared_examples.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'access restricted confidential issues' do + let(:query) { 'issue' } + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:project) { create(:project, :internal) } + + let!(:issue) { create(:issue, project: project, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) } + + subject(:objects) do + described_class.new(user, query, project: project).objects('issues') + end + + context 'when the user is non-member' do + let(:user) { create(:user) } + + it 'does not list project confidential issues for non project members' do + expect(objects).to contain_exactly(issue) + expect(results.limited_issues_count).to eq 1 + end + end + + context 'when the member is guest' do + let(:user) do + create(:user) { |guest| project.add_guest(guest) } + end + + it 'does not list project confidential issues for project members with guest role' do + expect(objects).to contain_exactly(issue) + expect(results.limited_issues_count).to eq 1 + end + end + + context 'when the user is the author' do + let(:user) { author } + + it 'lists project confidential issues' do + expect(objects).to contain_exactly(issue, + security_issue_1) + expect(results.limited_issues_count).to eq 2 + end + end + + context 'when the user is the assignee' do + let(:user) { assignee } + + it 'lists project confidential issues for assignee' do + expect(objects).to contain_exactly(issue, + security_issue_2) + expect(results.limited_issues_count).to eq 2 + end + end + + context 'when the user is a developper' do + let(:user) do + create(:user) { |user| project.add_developer(user) } + end + + it 'lists project confidential issues' do + expect(objects).to contain_exactly(issue, + security_issue_1, + security_issue_2) + expect(results.limited_issues_count).to eq 3 + end + end + + context 'when the user is admin', :request_store do + let(:user) { create(:user, admin: true) } + + it 'lists all project issues' do + expect(objects).to contain_exactly(issue, + security_issue_1, + security_issue_2) + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb index 4aeae788114..025f0d5c7ea 100644 --- a/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/repo_type_shared_examples.rb @@ -17,5 +17,9 @@ RSpec.shared_examples 'a repo type' do it 'finds the repository for the repo type' do expect(described_class.repository_for(expected_container)).to eq(expected_repository) end + + it 'returns nil when container is nil' do + expect(described_class.repository_for(nil)).to eq(nil) + end end end diff --git a/spec/support/shared_examples/lib/gitlab/search/recent_items.rb b/spec/support/shared_examples/lib/gitlab/search/recent_items.rb new file mode 100644 index 00000000000..f96ff4b101e --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/search/recent_items.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'search recent items' do + let_it_be(:user) { create(:user) } + let_it_be(:recent_items) { described_class.new(user: user, items_limit: 5) } + let(:item) { create_item(content: 'hello world 1', project: project) } + let(:project) { create(:project, :public) } + + describe '#log_view', :clean_gitlab_redis_shared_state do + it 'adds the item to the recent items' do + recent_items.log_view(item) + + results = recent_items.search('hello') + + expect(results).to eq([item]) + end + + it 'removes an item when it exceeds the size items_limit' do + (1..6).each do |i| + recent_items.log_view(create_item(content: "item #{i}", project: project)) + end + + results = recent_items.search('item') + + expect(results.map(&:title)).to contain_exactly('item 6', 'item 5', 'item 4', 'item 3', 'item 2') + end + + it 'expires the items after expires_after' do + recent_items = described_class.new(user: user, expires_after: 0) + + recent_items.log_view(item) + + results = recent_items.search('hello') + + expect(results).to be_empty + end + + it 'does not include results logged for another user' do + another_user = create(:user) + another_item = create_item(content: 'hello world 2', project: project) + described_class.new(user: another_user).log_view(another_item) + recent_items.log_view(item) + + results = recent_items.search('hello') + + expect(results).to eq([item]) + end + end + + describe '#search', :clean_gitlab_redis_shared_state do + let(:item1) { create_item(content: "matching item 1", project: project) } + let(:item2) { create_item(content: "matching item 2", project: project) } + let(:item3) { create_item(content: "matching item 3", project: project) } + let(:non_matching_item) { create_item(content: "different item", project: project) } + let!(:non_viewed_item) { create_item(content: "matching but not viewed item", project: project) } + + before do + recent_items.log_view(item1) + recent_items.log_view(item2) + recent_items.log_view(item3) + recent_items.log_view(non_matching_item) + end + + it 'matches partial text in the item title' do + expect(recent_items.search('matching')).to contain_exactly(item1, item2, item3) + end + + it 'returns results sorted by recently viewed' do + recent_items.log_view(item2) + + expect(recent_items.search('matching')).to eq([item2, item3, item1]) + end + + it 'does not leak items you no longer have access to' do + private_project = create(:project, :public, namespace: create(:group)) + private_item = create_item(content: 'matching item title', project: private_project) + + recent_items.log_view(private_item) + + private_project.update!(visibility_level: Project::PRIVATE) + + expect(recent_items.search('matching')).not_to include(private_item) + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb new file mode 100644 index 00000000000..e80ec516407 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/search_issue_state_filter_shared_examples.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'search results filtered by state' do + context 'state not provided' do + let(:filters) { {} } + + it 'returns opened and closed results', :aggregate_failures do + expect(results.objects(scope)).to include opened_result + expect(results.objects(scope)).to include closed_result + end + end + + context 'all state' do + let(:filters) { { state: 'all' } } + + it 'returns opened and closed results', :aggregate_failures do + expect(results.objects(scope)).to include opened_result + expect(results.objects(scope)).to include closed_result + end + end + + context 'closed state' do + let(:filters) { { state: 'closed' } } + + it 'returns only closed results', :aggregate_failures do + expect(results.objects(scope)).not_to include opened_result + expect(results.objects(scope)).to include closed_result + end + end + + context 'opened state' do + let(:filters) { { state: 'opened' } } + + it 'returns only opened results', :aggregate_failures do + expect(results.objects(scope)).to include opened_result + expect(results.objects(scope)).not_to include closed_result + end + end + + context 'unsupported state' do + let(:filters) { { state: 'hello' } } + + it 'returns only opened results', :aggregate_failures do + expect(results.objects(scope)).to include opened_result + expect(results.objects(scope)).to include closed_result + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb new file mode 100644 index 00000000000..73beef06855 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/sql/set_operator_shared_examples.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'SQL set operator' do |operator_keyword| + operator_keyword = operator_keyword.upcase + + let(:relation_1) { User.where(email: 'alice@example.com').select(:id) } + let(:relation_2) { User.where(email: 'bob@example.com').select(:id) } + + def to_sql(relation) + relation.reorder(nil).to_sql + end + + describe '.operator_keyword' do + it { expect(described_class.operator_keyword).to eq operator_keyword } + end + + describe '#to_sql' do + it "returns a String joining relations together using a #{operator_keyword}" do + set_operator = described_class.new([relation_1, relation_2]) + + expect(set_operator.to_sql).to eq("(#{to_sql(relation_1)})\n#{operator_keyword}\n(#{to_sql(relation_2)})") + end + + it 'skips Model.none segements' do + empty_relation = User.none + set_operator = described_class.new([empty_relation, relation_1, relation_2]) + + expect {User.where("users.id IN (#{set_operator.to_sql})").to_a}.not_to raise_error + expect(set_operator.to_sql).to eq("(#{to_sql(relation_1)})\n#{operator_keyword}\n(#{to_sql(relation_2)})") + end + + it "uses #{operator_keyword} ALL when removing duplicates is disabled" do + set_operator = described_class + .new([relation_1, relation_2], remove_duplicates: false) + + expect(set_operator.to_sql).to include("#{operator_keyword} ALL") + end + + it 'returns `NULL` if all relations are empty' do + empty_relation = User.none + set_operator = described_class.new([empty_relation, empty_relation]) + + expect(set_operator.to_sql).to eq('NULL') + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb new file mode 100644 index 00000000000..4e35e388b23 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/incident_management_activity_shared_examples.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'an incident management tracked event' do |event| + describe ".track_event", :clean_gitlab_redis_shared_state do + let(:counter) { Gitlab::UsageDataCounters::HLLRedisCounter } + let(:start_time) { 1.minute.ago } + let(:end_time) { 1.minute.from_now } + + it "tracks the event using redis" do + # Allow other subsequent calls + allow(Gitlab::UsageDataCounters::HLLRedisCounter) + .to receive(:track_event) + + expect(Gitlab::UsageDataCounters::HLLRedisCounter) + .to receive(:track_event) + .with(current_user.id, event.to_s) + .and_call_original + + expect { subject } + .to change { counter.unique_events(event_names: event.to_s, start_date: start_time, end_date: end_time) } + .by 1 + end + end +end + +RSpec.shared_examples 'does not track incident management event' do |event| + it 'does not track the event', :clean_gitlab_redis_shared_state do + expect(Gitlab::UsageDataCounters::HLLRedisCounter) + .not_to receive(:track_event) + .with(anything, event.to_s) + end +end diff --git a/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb b/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb index 6611a168c04..0ee24dd93d7 100644 --- a/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb +++ b/spec/support/shared_examples/models/chat_slash_commands_shared_examples.rb @@ -85,7 +85,7 @@ RSpec.shared_examples 'chat slash commands service' do let(:params) { { token: 'token', team_id: chat_name.team_id, user_id: chat_name.chat_id } } subject do - described_class.create(project: project, properties: { token: 'token' }) + described_class.create!(project: project, properties: { token: 'token' }) end it 'triggers the command' do diff --git a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb index 394253fb699..ac8022a4726 100644 --- a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples 'cluster application helm specs' do |application_name| - let(:application) { create(application_name) } + let(:application) { create(application_name) } # rubocop:disable Rails/SaveBang describe '#uninstall_command' do subject { application.uninstall_command } diff --git a/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb new file mode 100644 index 00000000000..6b208c0024d --- /dev/null +++ b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'from set operator' do |sql_klass| + from_set_operator_concern = described_class + operator_keyword = sql_klass.operator_keyword + operator_method = "from_#{sql_klass.operator_keyword.downcase}" + + describe "##{operator_method}" do + let(:model) do + Class.new(ActiveRecord::Base) do + self.table_name = 'users' + + include from_set_operator_concern + end + end + + it "selects from the results of the #{operator_keyword}" do + query = model.public_send(operator_method, [model.where(id: 1), model.where(id: 2)]) + + expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\n#{operator_keyword}\n\(SELECT.+\)\) users/m) + end + + it 'supports the use of a custom alias for the sub query' do + query = model.public_send(operator_method, + [model.where(id: 1), model.where(id: 2)], + alias_as: 'kittens' + ) + + expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\n#{operator_keyword}\n\(SELECT.+\)\) kittens/m) + end + + it 'supports keeping duplicate rows' do + query = model.public_send(operator_method, + [model.where(id: 1), model.where(id: 2)], + remove_duplicates: false + ) + + expect(query.to_sql) + .to match(/FROM \(\(SELECT.+\)\n#{operator_keyword} ALL\n\(SELECT.+\)\) users/m) + end + end +end diff --git a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb index d21823661f8..07d687147bc 100644 --- a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb @@ -8,26 +8,29 @@ RSpec.shared_examples 'includes Limitable concern' do context 'without plan limits configured' do it 'can create new models' do - expect { subject.save }.to change { described_class.count } + expect { subject.save! }.to change { described_class.count } end end context 'with plan limits configured' do before do - plan_limits.update(subject.class.limit_name => 1) + plan_limits.update!(subject.class.limit_name => 1) end it 'can create new models' do - expect { subject.save }.to change { described_class.count } + expect { subject.save! }.to change { described_class.count } end context 'with an existing model' do before do - subject.dup.save + subject.dup.save! end it 'cannot create new models exceeding the plan limits' do - expect { subject.save }.not_to change { described_class.count } + expect do + expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + .not_to change { described_class.count } expect(subject.errors[:base]).to contain_exactly("Maximum number of #{subject.class.limit_name.humanize(capitalize: false)} (1) exceeded") end end diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb index 15ca1f56bd0..d199bae4170 100644 --- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb @@ -102,7 +102,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| let(:timebox) { create(timebox_type, *timebox_args, group: group) } before do - project.update(group: group) + project.update!(group: group) end it "does not accept the same title in a group twice" do diff --git a/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb b/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb index 8c3e073193c..64390ccdc25 100644 --- a/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb +++ b/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb @@ -10,7 +10,7 @@ RSpec.shared_examples 'a valid diff note with after commit callback' do it 'raises an error' do allow(diff_file_from_repository).to receive(:line_for_position).with(position).and_return(nil) - expect { subject.save }.to raise_error(::DiffNote::NoteDiffFileCreationError, + expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError, "Failed to find diff line for: #{diff_file_from_repository.file_path}, "\ "old_line: #{position.old_line}"\ ", new_line: #{position.new_line}") @@ -25,11 +25,11 @@ RSpec.shared_examples 'a valid diff note with after commit callback' do it 'fallback to fetch file from repository' do expect_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository) - subject.save + subject.save! end it 'creates a diff note file' do - subject.save + subject.save! expect(subject.reload.note_diff_file).to be_present end @@ -40,7 +40,7 @@ RSpec.shared_examples 'a valid diff note with after commit callback' do it 'raises an error' do allow_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository).and_return(nil) - expect { subject.save }.to raise_error(::DiffNote::NoteDiffFileCreationError, 'Failed to find diff file') + expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError, 'Failed to find diff file') end end end diff --git a/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb b/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb index b0cdc77a378..759b22f794e 100644 --- a/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb +++ b/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb @@ -18,8 +18,6 @@ RSpec.shared_examples 'a valid diff positionable note' do |factory_on_commit| ) end - subject { build(factory_on_commit, commit_id: commit_id, position: position) } - context 'position diff refs matches commit diff refs' do it 'is valid' do expect(subject).to be_valid diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index 9bf157212d3..7ede6f0d8d4 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -25,21 +25,21 @@ RSpec.shared_examples 'inherited access level as a member of entity' do it 'is allowed to change to be a developer of the entity' do entity.add_maintainer(user) - expect { member.update(access_level: Gitlab::Access::DEVELOPER) } + expect { member.update!(access_level: Gitlab::Access::DEVELOPER) } .to change { member.access_level }.to(Gitlab::Access::DEVELOPER) end it 'is not allowed to change to be a guest of the entity' do entity.add_maintainer(user) - expect { member.update(access_level: Gitlab::Access::GUEST) } + expect { member.update(access_level: Gitlab::Access::GUEST) } # rubocop:disable Rails/SaveBang .not_to change { member.reload.access_level } end it "shows an error if the member can't be updated" do entity.add_maintainer(user) - member.update(access_level: Gitlab::Access::REPORTER) + expect { member.update!(access_level: Gitlab::Access::REPORTER) }.to raise_error(ActiveRecord::RecordInvalid) expect(member.errors.full_messages).to eq(["Access level should be greater than or equal to Developer inherited membership from group #{parent_entity.name}"]) end @@ -51,7 +51,7 @@ RSpec.shared_examples 'inherited access level as a member of entity' do non_member = entity.is_a?(Group) ? entity.group_member(non_member_user) : entity.project_member(non_member_user) - expect { non_member.update(access_level: Gitlab::Access::GUEST) } + expect { non_member.update!(access_level: Gitlab::Access::GUEST) } .to change { non_member.reload.access_level } end end @@ -60,7 +60,7 @@ end RSpec.shared_examples '#valid_level_roles' do |entity_name| let(:member_user) { create(:user) } let(:group) { create(:group) } - let(:entity) { create(entity_name) } + let(:entity) { create(entity_name) } # rubocop:disable Rails/SaveBang let(:entity_member) { create("#{entity_name}_member", :developer, source: entity, user: member_user) } let(:presenter) { described_class.new(entity_member, current_user: member_user) } let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Reporter' => 20 } } diff --git a/spec/support/shared_examples/models/members_notifications_shared_example.rb b/spec/support/shared_examples/models/members_notifications_shared_example.rb index 050d710f1de..04af3935d15 100644 --- a/spec/support/shared_examples/models/members_notifications_shared_example.rb +++ b/spec/support/shared_examples/models/members_notifications_shared_example.rb @@ -13,7 +13,7 @@ RSpec.shared_examples 'members notifications' do |entity_type| it "sends email to user" do expect(notification_service).to receive(:"new_#{entity_type}_member").with(member) - member.save + member.save! end end diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb index dda5fa37b26..94c52bdaaa6 100644 --- a/spec/support/shared_examples/models/mentionable_shared_examples.rb +++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb @@ -162,7 +162,7 @@ RSpec.shared_examples 'an editable mentionable' do end it 'creates new cross-reference notes when the mentionable text is edited' do - subject.save + subject.save! subject.create_cross_references! new_text = <<-MSG.strip_heredoc @@ -270,7 +270,7 @@ RSpec.shared_examples 'mentions in notes' do |mentionable_type| let!(:mentionable) { note.noteable } before do - note.update(note: note_desc) + note.update!(note: note_desc) note.store_mentions! add_member(user) end @@ -292,7 +292,7 @@ RSpec.shared_examples 'load mentions from DB' do |mentionable_type| let_it_be(:note_desc) { "#{mentioned_user.to_reference} and #{group.to_reference(full: true)} and @all" } before do - note.update(note: note_desc) + note.update!(note: note_desc) note.store_mentions! add_member(user) end @@ -305,7 +305,7 @@ RSpec.shared_examples 'load mentions from DB' do |mentionable_type| mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << non_existing_record_id, mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << non_existing_record_id } - user_mention.update(mention_ids) + user_mention.update!(mention_ids) end it 'filters out inexistent mentions' do @@ -328,7 +328,7 @@ RSpec.shared_examples 'load mentions from DB' do |mentionable_type| mentioned_projects_ids: user_mention.mentioned_projects_ids.to_a << private_project.id, mentioned_groups_ids: user_mention.mentioned_groups_ids.to_a << private_group.id } - user_mention.update(mention_ids) + user_mention.update!(mention_ids) add_member(mega_user) private_project.add_developer(mega_user) diff --git a/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb b/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb index 7bbc0c5a364..7701ab42007 100644 --- a/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb +++ b/spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb @@ -53,7 +53,7 @@ RSpec.shared_examples 'latest successful build for sha or ref' do let(:build_name) { pending_build.name } before do - pipeline.update(status: 'pending') + pipeline.update!(status: 'pending') end it 'returns empty relation' do diff --git a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb index e4668926d74..d1437244082 100644 --- a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb +++ b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb @@ -1,9 +1,25 @@ # frozen_string_literal: true +# Notes for implementing classes: +# +# The following let bindings should be defined: +# - `factory`: A symbol naming a factory to use to create items +# - `default_params`: A HashMap of factory parameters to pass to the factory. +# +# The `default_params` should include the relative parent, so that any item +# created with these parameters passed to the `factory` will be considered in +# the same set of items relative to each other. +# +# For the purposes of efficiency, it is a good idea to bind the parent in +# `let_it_be`, so that it is re-used across examples, but be careful that it +# does not have any other children - it should only be used within this set of +# shared examples. RSpec.shared_examples 'a class that supports relative positioning' do let(:item1) { create_item } let(:item2) { create_item } - let(:new_item) { create_item } + let(:new_item) { create_item(relative_position: nil) } + + let(:set_size) { RelativePositioning.mover.context(item1).scoped_items.count } def create_item(params = {}) create(factory, params.merge(default_params)) @@ -17,6 +33,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do describe '.move_nulls_to_end' do let(:item3) { create_item } + let(:sibling_query) { item1.class.relative_positioning_query_base(item1) } it 'moves items with null relative_position to the end' do item1.update!(relative_position: 1000) @@ -28,10 +45,9 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(items.sort_by(&:relative_position)).to eq(items) expect(item1.relative_position).to be(1000) - expect(item1.prev_relative_position).to be_nil - expect(item1.next_relative_position).to eq(item2.relative_position) - expect(item2.next_relative_position).to eq(item3.relative_position) - expect(item3.next_relative_position).to be_nil + + expect(sibling_query.where(relative_position: nil)).not_to exist + expect(sibling_query.reorder(:relative_position, :id)).to eq([item1, item2, item3]) end it 'preserves relative position' do @@ -70,6 +86,37 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(items.sort_by(&:relative_position)).to eq(items) end + it 'manages to move nulls to the end even if there is not enough space' do + run = run_at_end(20).to_a + bunch_a = create_items_with_positions(run[0..18]) + bunch_b = create_items_with_positions([run.last]) + + nils = create_items_with_positions([nil] * 4) + described_class.move_nulls_to_end(nils) + + items = [*bunch_a, *bunch_b, *nils] + items.each(&:reset) + + expect(items.map(&:relative_position)).to all(be_valid_position) + expect(items.reverse.sort_by(&:relative_position)).to eq(items) + end + + it 'manages to move nulls to the end, stacking if we cannot create enough space' do + run = run_at_end(40).to_a + bunch = create_items_with_positions(run.select(&:even?)) + + nils = create_items_with_positions([nil] * 20) + described_class.move_nulls_to_end(nils) + + items = [*bunch, *nils] + items.each(&:reset) + + expect(items.map(&:relative_position)).to all(be_valid_position) + expect(bunch.reverse.sort_by(&:relative_position)).to eq(bunch) + expect(nils.reverse.sort_by(&:relative_position)).not_to eq(nils) + expect(bunch.map(&:relative_position)).to all(be < nils.map(&:relative_position).min) + end + it 'does not have an N+1 issue' do create_items_with_positions(10..12) @@ -89,6 +136,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do describe '.move_nulls_to_start' do let(:item3) { create_item } + let(:sibling_query) { item1.class.relative_positioning_query_base(item1) } it 'moves items with null relative_position to the start' do item1.update!(relative_position: nil) @@ -100,10 +148,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do items.map(&:reload) expect(items.sort_by(&:relative_position)).to eq(items) - expect(item1.prev_relative_position).to eq nil - expect(item1.next_relative_position).to eq item2.relative_position - expect(item2.next_relative_position).to eq item3.relative_position - expect(item3.next_relative_position).to eq nil + expect(sibling_query.where(relative_position: nil)).not_to exist + expect(sibling_query.reorder(:relative_position, :id)).to eq(items) expect(item3.relative_position).to be(1000) end @@ -130,193 +176,36 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(described_class.move_nulls_to_start([item1])).to be(0) expect(item1.reload.relative_position).to be(1) end - end - - describe '#max_relative_position' do - it 'returns maximum position' do - expect(item1.max_relative_position).to eq item2.relative_position - end - end - - describe '#prev_relative_position' do - it 'returns previous position if there is an item above' do - item1.update!(relative_position: 5) - item2.update!(relative_position: 15) - - expect(item2.prev_relative_position).to eq item1.relative_position - end - - it 'returns nil if there is no item above' do - expect(item1.prev_relative_position).to eq nil - end - end - - describe '#next_relative_position' do - it 'returns next position if there is an item below' do - item1.update!(relative_position: 5) - item2.update!(relative_position: 15) - - expect(item1.next_relative_position).to eq item2.relative_position - end - - it 'returns nil if there is no item below' do - expect(item2.next_relative_position).to eq nil - end - end - - describe '#find_next_gap_before' do - context 'there is no gap' do - let(:items) { create_items_with_positions(run_at_start) } - - it 'returns nil' do - items.each do |item| - expect(item.send(:find_next_gap_before)).to be_nil - end - end - end - - context 'there is a sequence ending at MAX_POSITION' do - let(:items) { create_items_with_positions(run_at_end) } - - let(:gaps) do - items.map { |item| item.send(:find_next_gap_before) } - end - - it 'can find the gap at the start for any item in the sequence' do - gap = { start: items.first.relative_position, end: RelativePositioning::MIN_POSITION } - - expect(gaps).to all(eq(gap)) - end - - it 'respects lower bounds' do - gap = { start: items.first.relative_position, end: 10 } - new_item.update!(relative_position: 10) - - expect(gaps).to all(eq(gap)) - end - end - - specify do - item1.update!(relative_position: 5) - - (0..10).each do |pos| - item2.update!(relative_position: pos) - - gap = item2.send(:find_next_gap_before) - - expect(gap[:start]).to be <= item2.relative_position - expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP - expect(gap[:start]).to be_valid_position - expect(gap[:end]).to be_valid_position - end - end - - it 'deals with there not being any items to the left' do - create_items_with_positions([1, 2, 3]) - new_item.update!(relative_position: 0) - - expect(new_item.send(:find_next_gap_before)).to eq(start: 0, end: RelativePositioning::MIN_POSITION) - end - - it 'finds the next gap to the left, skipping adjacent values' do - create_items_with_positions([1, 9, 10]) - new_item.update!(relative_position: 11) - - expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 1) - end - - it 'finds the next gap to the left' do - create_items_with_positions([2, 10]) - - new_item.update!(relative_position: 15) - expect(new_item.send(:find_next_gap_before)).to eq(start: 15, end: 10) - - new_item.update!(relative_position: 11) - expect(new_item.send(:find_next_gap_before)).to eq(start: 10, end: 2) - - new_item.update!(relative_position: 9) - expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 2) - - new_item.update!(relative_position: 5) - expect(new_item.send(:find_next_gap_before)).to eq(start: 5, end: 2) - end - end - - describe '#find_next_gap_after' do - context 'there is no gap' do - let(:items) { create_items_with_positions(run_at_end) } - it 'returns nil' do - items.each do |item| - expect(item.send(:find_next_gap_after)).to be_nil - end - end - end - - context 'there is a sequence starting at MIN_POSITION' do - let(:items) { create_items_with_positions(run_at_start) } - - let(:gaps) do - items.map { |item| item.send(:find_next_gap_after) } - end - - it 'can find the gap at the end for any item in the sequence' do - gap = { start: items.last.relative_position, end: RelativePositioning::MAX_POSITION } - - expect(gaps).to all(eq(gap)) - end + it 'manages to move nulls to the start even if there is not enough space' do + run = run_at_start(20).to_a + bunch_a = create_items_with_positions([run.first]) + bunch_b = create_items_with_positions(run[2..]) - it 'respects upper bounds' do - gap = { start: items.last.relative_position, end: 10 } - new_item.update!(relative_position: 10) + nils = create_items_with_positions([nil, nil, nil, nil]) + described_class.move_nulls_to_start(nils) - expect(gaps).to all(eq(gap)) - end - end - - specify do - item1.update!(relative_position: 5) - - (0..10).each do |pos| - item2.update!(relative_position: pos) - - gap = item2.send(:find_next_gap_after) - - expect(gap[:start]).to be >= item2.relative_position - expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP - expect(gap[:start]).to be_valid_position - expect(gap[:end]).to be_valid_position - end - end - - it 'deals with there not being any items to the right' do - create_items_with_positions([1, 2, 3]) - new_item.update!(relative_position: 5) - - expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: RelativePositioning::MAX_POSITION) - end - - it 'finds the next gap to the right, skipping adjacent values' do - create_items_with_positions([1, 2, 10]) - new_item.update!(relative_position: 0) + items = [*nils, *bunch_a, *bunch_b] + items.each(&:reset) - expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10) + expect(items.map(&:relative_position)).to all(be_valid_position) + expect(items.reverse.sort_by(&:relative_position)).to eq(items) end - it 'finds the next gap to the right' do - create_items_with_positions([2, 10]) + it 'manages to move nulls to the end, stacking if we cannot create enough space' do + run = run_at_start(40).to_a + bunch = create_items_with_positions(run.select(&:even?)) - new_item.update!(relative_position: 0) - expect(new_item.send(:find_next_gap_after)).to eq(start: 0, end: 2) + nils = create_items_with_positions([nil].cycle.take(20)) + described_class.move_nulls_to_start(nils) - new_item.update!(relative_position: 1) - expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10) - - new_item.update!(relative_position: 3) - expect(new_item.send(:find_next_gap_after)).to eq(start: 3, end: 10) + items = [*nils, *bunch] + items.each(&:reset) - new_item.update!(relative_position: 5) - expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: 10) + expect(items.map(&:relative_position)).to all(be_valid_position) + expect(bunch.reverse.sort_by(&:relative_position)).to eq(bunch) + expect(nils.reverse.sort_by(&:relative_position)).not_to eq(nils) + expect(bunch.map(&:relative_position)).to all(be > nils.map(&:relative_position).max) end end @@ -384,36 +273,39 @@ RSpec.shared_examples 'a class that supports relative positioning' do end context 'leap-frogging to the left' do + let(:item3) { create(factory, default_params) } + let(:start) { RelativePositioning::START_POSITION } + before do - start = RelativePositioning::START_POSITION item1.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 0) item2.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 1) item3.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 2) end - let(:item3) { create(factory, default_params) } + def leap_frog + a, b = [item1.reset, item2.reset].sort_by(&:relative_position) - def leap_frog(steps) - a = item1 - b = item2 - - steps.times do |i| - a.move_before(b) - a.save! - a, b = b, a - end + b.move_before(a) + b.save! end - it 'can leap-frog STEPS - 1 times before needing to rebalance' do - # This is less efficient than going right, due to the flooring of - # integer division - expect { leap_frog(RelativePositioning::STEPS - 1) } - .not_to change { item3.reload.relative_position } + it 'can leap-frog STEPS times before needing to rebalance' do + expect { RelativePositioning::STEPS.times { leap_frog } } + .to change { item3.reload.relative_position }.by(0) + .and change { item1.reload.relative_position }.by(be < 0) + .and change { item2.reload.relative_position }.by(be < 0) + + expect { leap_frog } + .to change { item3.reload.relative_position }.by(be < 0) end - it 'rebalances after leap-frogging STEPS times' do - expect { leap_frog(RelativePositioning::STEPS) } - .to change { item3.reload.relative_position } + context 'there is no space to the left after moving STEPS times' do + let(:start) { RelativePositioning::MIN_POSITION + (2 * RelativePositioning::IDEAL_DISTANCE) } + + it 'rebalances to the right' do + expect { RelativePositioning::STEPS.succ.times { leap_frog } } + .not_to change { item3.reload.relative_position } + end end end end @@ -476,25 +368,25 @@ RSpec.shared_examples 'a class that supports relative positioning' do let(:item3) { create(factory, default_params) } - def leap_frog(steps) - a = item1 - b = item2 + def leap_frog + a, b = [item1.reset, item2.reset].sort_by(&:relative_position) - steps.times do |i| - a.move_after(b) - a.save! - a, b = b, a - end + a.move_after(b) + a.save! end - it 'can leap-frog STEPS times before needing to rebalance' do - expect { leap_frog(RelativePositioning::STEPS) } - .not_to change { item3.reload.relative_position } - end + it 'rebalances after STEPS jumps' do + RelativePositioning::STEPS.pred.times do + expect { leap_frog } + .to change { item3.reload.relative_position }.by(0) + .and change { item1.reset.relative_position }.by(be >= 0) + .and change { item2.reset.relative_position }.by(be >= 0) + end - it 'rebalances after leap-frogging STEPS+1 times' do - expect { leap_frog(RelativePositioning::STEPS + 1) } - .to change { item3.reload.relative_position } + expect { leap_frog } + .to change { item3.reload.relative_position }.by(0) + .and change { item1.reset.relative_position }.by(be < 0) + .and change { item2.reset.relative_position }.by(be < 0) end end end @@ -506,12 +398,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do end end + it 'places items at most IDEAL_DISTANCE from the start when the range is open' do + n = set_size + + expect([item1, item2].map(&:relative_position)).to all(be >= (RelativePositioning::START_POSITION - (n * RelativePositioning::IDEAL_DISTANCE))) + end + it 'moves item to the end' do new_item.move_to_start expect(new_item.relative_position).to be < item2.relative_position end + it 'positions the item at MIN_POSITION when there is only one space left' do + item2.update!(relative_position: RelativePositioning::MIN_POSITION + 1) + + new_item.move_to_start + + expect(new_item.relative_position).to eq RelativePositioning::MIN_POSITION + end + it 'rebalances when there is already an item at the MIN_POSITION' do item2.update!(relative_position: RelativePositioning::MIN_POSITION) @@ -543,12 +449,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do end end + it 'places items at most IDEAL_DISTANCE from the start when the range is open' do + n = set_size + + expect([item1, item2].map(&:relative_position)).to all(be <= (RelativePositioning::START_POSITION + (n * RelativePositioning::IDEAL_DISTANCE))) + end + it 'moves item to the end' do new_item.move_to_end expect(new_item.relative_position).to be > item2.relative_position end + it 'positions the item at MAX_POSITION when there is only one space left' do + item2.update!(relative_position: RelativePositioning::MAX_POSITION - 1) + + new_item.move_to_end + + expect(new_item.relative_position).to eq RelativePositioning::MAX_POSITION + end + it 'rebalances when there is already an item at the MAX_POSITION' do item2.update!(relative_position: RelativePositioning::MAX_POSITION) @@ -712,63 +632,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do end end - describe '#move_sequence_before' do - it 'moves the whole sequence of items to the middle of the nearest gap' do - items = create_items_with_positions([90, 100, 101, 102]) - - items.last.move_sequence_before - items.last.save! - - positions = items.map { |item| item.reload.relative_position } - expect(positions).to eq([90, 95, 96, 102]) - end - - it 'raises an error if there is no space' do - items = create_items_with_positions(run_at_start) - - expect { items.last.move_sequence_before }.to raise_error(RelativePositioning::NoSpaceLeft) - end - - it 'finds a gap if there are unused positions' do - items = create_items_with_positions([100, 101, 102]) - - items.last.move_sequence_before - items.last.save! - - positions = items.map { |item| item.reload.relative_position } - - expect(positions.last - positions.second).to be > RelativePositioning::MIN_GAP - end - end - - describe '#move_sequence_after' do - it 'moves the whole sequence of items to the middle of the nearest gap' do - items = create_items_with_positions([100, 101, 102, 110]) - - items.first.move_sequence_after - items.first.save! - - positions = items.map { |item| item.reload.relative_position } - expect(positions).to eq([100, 105, 106, 110]) - end - - it 'finds a gap if there are unused positions' do - items = create_items_with_positions([100, 101, 102]) - - items.first.move_sequence_after - items.first.save! - - positions = items.map { |item| item.reload.relative_position } - expect(positions.second - positions.first).to be > RelativePositioning::MIN_GAP - end - - it 'raises an error if there is no space' do - items = create_items_with_positions(run_at_end) - - expect { items.first.move_sequence_after }.to raise_error(RelativePositioning::NoSpaceLeft) - end - end - def be_valid_position be_between(RelativePositioning::MIN_POSITION, RelativePositioning::MAX_POSITION) end diff --git a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb index a5228c43f6f..a1867e1ce39 100644 --- a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb @@ -164,7 +164,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| context "event channels" do it "uses the right channel for push event" do - chat_service.update(push_channel: "random") + chat_service.update!(push_channel: "random") expect(Slack::Messenger).to execute_with_options(channel: ['random']) @@ -172,7 +172,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it "uses the right channel for merge request event" do - chat_service.update(merge_request_channel: "random") + chat_service.update!(merge_request_channel: "random") expect(Slack::Messenger).to execute_with_options(channel: ['random']) @@ -180,7 +180,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it "uses the right channel for issue event" do - chat_service.update(issue_channel: "random") + chat_service.update!(issue_channel: "random") expect(Slack::Messenger).to execute_with_options(channel: ['random']) @@ -191,7 +191,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| let(:issue_service_options) { { title: 'Secret', confidential: true } } it "uses confidential issue channel" do - chat_service.update(confidential_issue_channel: 'confidential') + chat_service.update!(confidential_issue_channel: 'confidential') expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) @@ -199,7 +199,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it 'falls back to issue channel' do - chat_service.update(issue_channel: 'fallback_channel') + chat_service.update!(issue_channel: 'fallback_channel') expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel']) @@ -208,7 +208,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it "uses the right channel for wiki event" do - chat_service.update(wiki_page_channel: "random") + chat_service.update!(wiki_page_channel: "random") expect(Slack::Messenger).to execute_with_options(channel: ['random']) @@ -221,7 +221,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it "uses the right channel" do - chat_service.update(note_channel: "random") + chat_service.update!(note_channel: "random") note_data = Gitlab::DataBuilder::Note.build(issue_note, user) @@ -236,7 +236,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it "uses confidential channel" do - chat_service.update(confidential_note_channel: "confidential") + chat_service.update!(confidential_note_channel: "confidential") note_data = Gitlab::DataBuilder::Note.build(issue_note, user) @@ -246,7 +246,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| end it 'falls back to note channel' do - chat_service.update(note_channel: "fallback_channel") + chat_service.update!(note_channel: "fallback_channel") note_data = Gitlab::DataBuilder::Note.build(issue_note, user) diff --git a/spec/support/shared_examples/models/throttled_touch_shared_examples.rb b/spec/support/shared_examples/models/throttled_touch_shared_examples.rb index fc4f6053bb9..14b851d2828 100644 --- a/spec/support/shared_examples/models/throttled_touch_shared_examples.rb +++ b/spec/support/shared_examples/models/throttled_touch_shared_examples.rb @@ -3,7 +3,7 @@ RSpec.shared_examples 'throttled touch' do describe '#touch' do it 'updates the updated_at timestamp' do - Timecop.freeze do + freeze_time do subject.touch expect(subject.updated_at).to be_like_time(Time.zone.now) 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 7f0da19996e..557025569b8 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 @@ -105,7 +105,7 @@ RSpec.shared_examples 'UpdateProjectStatistics' do expect(ProjectStatistics) .not_to receive(:increment_statistic) - project.update(pending_delete: true) + project.update!(pending_delete: true) project.destroy! end @@ -113,7 +113,7 @@ RSpec.shared_examples 'UpdateProjectStatistics' do expect(Namespaces::ScheduleAggregationWorker) .not_to receive(:perform_async) - project.update(pending_delete: true) + project.update!(pending_delete: true) project.destroy! end end diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb index a881d5f036c..b87f7fe97e1 100644 --- a/spec/support/shared_examples/models/wiki_shared_examples.rb +++ b/spec/support/shared_examples/models/wiki_shared_examples.rb @@ -322,8 +322,8 @@ RSpec.shared_examples 'wiki model' do expect(commit.committer_email).to eq(user.commit_email) end - it 'updates container activity' do - expect(subject).to receive(:update_container_activity) + it 'runs after_wiki_activity callbacks' do + expect(subject).to receive(:after_wiki_activity) subject.create_page('Test Page', 'This is content') end @@ -363,10 +363,10 @@ RSpec.shared_examples 'wiki model' do expect(commit.committer_email).to eq(user.commit_email) end - it 'updates container activity' do + it 'runs after_wiki_activity callbacks' do page - expect(subject).to receive(:update_container_activity) + expect(subject).to receive(:after_wiki_activity) update_page end @@ -389,10 +389,10 @@ RSpec.shared_examples 'wiki model' do expect(commit.committer_email).to eq(user.commit_email) end - it 'updates container activity' do + it 'runs after_wiki_activity callbacks' do page - expect(subject).to receive(:update_container_activity) + expect(subject).to receive(:after_wiki_activity) subject.delete_page(page) end diff --git a/spec/support/shared_examples/models/with_uploads_shared_examples.rb b/spec/support/shared_examples/models/with_uploads_shared_examples.rb index f2a4d9919b7..0c930ec1fce 100644 --- a/spec/support/shared_examples/models/with_uploads_shared_examples.rb +++ b/spec/support/shared_examples/models/with_uploads_shared_examples.rb @@ -12,7 +12,7 @@ RSpec.shared_examples 'model with uploads' do |supports_fileuploads| it 'deletes remote uploads' do expect_any_instance_of(CarrierWave::Storage::Fog::File).to receive(:delete).and_call_original - expect { model_object.destroy }.to change { Upload.count }.by(-1) + expect { model_object.destroy! }.to change { Upload.count }.by(-1) end end @@ -21,13 +21,13 @@ RSpec.shared_examples 'model with uploads' do |supports_fileuploads| let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: model_object) } it 'deletes any FileUploader uploads which are not mounted' do - expect { model_object.destroy }.to change { Upload.count }.by(-3) + expect { model_object.destroy! }.to change { Upload.count }.by(-3) end it 'deletes local files' do expect_any_instance_of(Uploads::Local).to receive(:delete_keys).with(uploads.map(&:absolute_path)) - model_object.destroy + model_object.destroy! end end @@ -35,14 +35,14 @@ RSpec.shared_examples 'model with uploads' do |supports_fileuploads| let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: model_object) } it 'deletes any FileUploader uploads which are not mounted' do - expect { model_object.destroy }.to change { Upload.count }.by(-3) + expect { model_object.destroy! }.to change { Upload.count }.by(-3) end it 'deletes remote files' do expected_array = array_including(*uploads.map(&:path)) expect_any_instance_of(Uploads::Fog).to receive(:delete_keys).with(expected_array) - model_object.destroy + model_object.destroy! end end end diff --git a/spec/support/shared_examples/path_extraction_shared_examples.rb b/spec/support/shared_examples/path_extraction_shared_examples.rb index ff55bc9a490..39c7c1f2a94 100644 --- a/spec/support/shared_examples/path_extraction_shared_examples.rb +++ b/spec/support/shared_examples/path_extraction_shared_examples.rb @@ -146,20 +146,6 @@ RSpec.shared_examples 'extracts refs' do expect(extract_ref('release/app/doc/README.md')).to eq(['release/app', 'doc/README.md']) end - - context 'when the extracts_path_optimization feature flag is disabled' do - before do - stub_feature_flags(extracts_path_optimization: false) - end - - it 'always fetches all ref names' do - expect(self).to receive(:ref_names).and_call_original - expect(container.repository).not_to receive(:branch_names_include?) - expect(container.repository).not_to receive(:tag_names_include?) - - expect(extract_ref('v1.0.0/doc/README.md')).to eq(['v1.0.0', 'doc/README.md']) - end - end end context 'when the repository has ambiguous refs' do diff --git a/spec/support/shared_examples/policies/project_policy_shared_examples.rb b/spec/support/shared_examples/policies/project_policy_shared_examples.rb index d8476f5dcc2..d05e5eb9120 100644 --- a/spec/support/shared_examples/policies/project_policy_shared_examples.rb +++ b/spec/support/shared_examples/policies/project_policy_shared_examples.rb @@ -59,8 +59,7 @@ RSpec.shared_examples 'project policies as anonymous' do let(:project) { create(:project, :public, namespace: group) } let(:user_permissions) { [:create_merge_request_in, :create_project, :create_issue, :create_note, :upload_file, :award_emoji] } let(:anonymous_permissions) { guest_permissions - user_permissions } - - subject { described_class.new(nil, project) } + let(:current_user) { anonymous } before do create(:group_member, :invited, group: group) @@ -78,9 +77,8 @@ RSpec.shared_examples 'project policies as anonymous' do end context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(nil, project) } + let(:project) { private_project } + let(:current_user) { anonymous } it { is_expected.to be_banned } end @@ -109,10 +107,10 @@ RSpec.shared_examples 'deploy token does not get confused with user' do end RSpec.shared_examples 'project policies as guest' do - subject { described_class.new(guest, project) } - context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } + let(:project) { private_project } + let(:current_user) { guest } + let(:reporter_public_build_permissions) do reporter_permissions - [:read_build, :read_pipeline] end @@ -167,9 +165,8 @@ end RSpec.shared_examples 'project policies as reporter' do context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(reporter, project) } + let(:project) { private_project } + let(:current_user) { reporter } it do expect_allowed(*guest_permissions) @@ -192,9 +189,8 @@ end RSpec.shared_examples 'project policies as developer' do context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(developer, project) } + let(:project) { private_project } + let(:current_user) { developer } it do expect_allowed(*guest_permissions) @@ -217,9 +213,8 @@ end RSpec.shared_examples 'project policies as maintainer' do context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(maintainer, project) } + let(:project) { private_project } + let(:current_user) { maintainer } it do expect_allowed(*guest_permissions) @@ -242,9 +237,8 @@ end RSpec.shared_examples 'project policies as owner' do context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(owner, project) } + let(:project) { private_project } + let(:current_user) { owner } it do expect_allowed(*guest_permissions) @@ -267,9 +261,8 @@ end RSpec.shared_examples 'project policies as admin with admin mode' do context 'abilities for non-public projects', :enable_admin_mode do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(admin, project) } + let(:project) { private_project } + let(:current_user) { admin } it do expect_allowed(*guest_permissions) @@ -316,9 +309,8 @@ end RSpec.shared_examples 'project policies as admin without admin mode' do context 'abilities for non-public projects' do - let(:project) { create(:project, namespace: owner.namespace) } - - subject { described_class.new(admin, project) } + let(:project) { private_project } + let(:current_user) { admin } it { is_expected.to be_banned } diff --git a/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb b/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb index 9bfd1e6faa0..e94d29febfb 100644 --- a/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true -# Shared examples to that test code that creates AwardEmoji also mark Todos -# as done. +# Shared examples to test that the code that creates AwardEmoji also marks +# ToDos as done. # # The examples expect these to be defined in the calling spec: # - `subject` the callable code that executes the creation of an AwardEmoji # - `user` # - `project` +# RSpec.shared_examples 'creating award emojis marks Todos as done' do using RSpec::Parameterized::TableSyntax @@ -22,7 +23,7 @@ RSpec.shared_examples 'creating award emojis marks Todos as done' do with_them do let(:project) { awardable.project } - let(:awardable) { create(type) } + let(:awardable) { create(type) } # rubocop:disable Rails/SaveBang let!(:todo) { create(:todo, target: awardable, project: project, user: user) } specify do diff --git a/spec/support/shared_examples/requests/api/boards_shared_examples.rb b/spec/support/shared_examples/requests/api/boards_shared_examples.rb index 20b0f4f0dd2..0096aab55e3 100644 --- a/spec/support/shared_examples/requests/api/boards_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/boards_shared_examples.rb @@ -169,7 +169,7 @@ RSpec.shared_examples 'group and project boards' do |route_definition, ee = fals before do if board_parent.try(:namespace) - board_parent.update(namespace: owner.namespace) + board_parent.update!(namespace: owner.namespace) else board.resource_parent.add_owner(owner) end diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb index 09743c20fba..5c122b4b5d6 100644 --- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb @@ -16,8 +16,11 @@ RSpec.shared_examples 'Composer package index' do |user_type, status, add_member subject expect(response).to have_gitlab_http_status(status) - expect(response).to match_response_schema('public_api/v4/packages/composer/index') - expect(json_response).to eq presenter.root + + if status == :success + expect(response).to match_response_schema('public_api/v4/packages/composer/index') + expect(json_response).to eq presenter.root + end end end end @@ -87,13 +90,22 @@ RSpec.shared_examples 'process Composer api request' do |user_type, status, add_ end end -RSpec.shared_context 'Composer auth headers' do |user_role, user_token| +RSpec.shared_context 'Composer auth headers' do |user_role, user_token, auth_method = :token| let(:token) { user_token ? personal_access_token.token : 'wrong' } - let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + let(:headers) do + if user_role == :anonymous + {} + elsif auth_method == :token + { 'Private-Token' => token } + else + basic_auth_header(user.username, token) + end + end end -RSpec.shared_context 'Composer api project access' do |project_visibility_level, user_role, user_token| - include_context 'Composer auth headers', user_role, user_token do +RSpec.shared_context 'Composer api project access' do |project_visibility_level, user_role, user_token, auth_method| + include_context 'Composer auth headers', user_role, user_token, auth_method do before do project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) end diff --git a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb new file mode 100644 index 00000000000..c56290a0aa9 --- /dev/null +++ b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb @@ -0,0 +1,843 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'conan ping endpoint' do + it 'responds with 401 Unauthorized when no token provided' do + get api(url) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 200 OK when valid token is provided' do + jwt = build_jwt(personal_access_token) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Conan-Server-Capabilities']).to eq("") + end + + it 'responds with 200 OK when valid job token is provided' do + jwt = build_jwt_from_job(job) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Conan-Server-Capabilities']).to eq("") + end + + it 'responds with 200 OK when valid deploy token is provided' do + jwt = build_jwt_from_deploy_token(deploy_token) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Conan-Server-Capabilities']).to eq("") + end + + it 'responds with 401 Unauthorized when invalid access token ID is provided' do + jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when invalid user is provided' do + jwt = build_jwt(personal_access_token, user_id: 12345) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do + jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32)) + get api(url), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when invalid JWT is provided' do + get api(url), headers: build_token_auth_header('invalid-jwt') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + context 'packages feature disabled' do + it 'responds with 404 Not Found' do + stub_packages_setting(enabled: false) + get api(url) + + expect(response).to have_gitlab_http_status(:not_found) + end + end +end + +RSpec.shared_examples 'conan search endpoint' do + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel::PUBLIC) + + get api(url), headers: headers, params: params + end + + subject { json_response['results'] } + + context 'returns packages with a matching name' do + let(:params) { { q: package.conan_recipe } } + + it { is_expected.to contain_exactly(package.conan_recipe) } + end + + context 'returns packages using a * wildcard' do + let(:params) { { q: "#{package.name[0, 3]}*" } } + + it { is_expected.to contain_exactly(package.conan_recipe) } + end + + context 'does not return non-matching packages' do + let(:params) { { q: "foo" } } + + it { is_expected.to be_blank } + end +end + +RSpec.shared_examples 'conan authenticate endpoint' do + subject { get api(url), headers: headers } + + context 'when using invalid token' do + let(:auth_token) { 'invalid_token' } + + it 'responds with 401' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when valid JWT access token is provided' do + it 'responds with 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'token has valid validity time' do + freeze_time do + subject + + payload = JSONWebToken::HMACToken.decode( + response.body, jwt_secret).first + expect(payload['access_token']).to eq(personal_access_token.id) + expect(payload['user_id']).to eq(personal_access_token.user_id) + + duration = payload['exp'] - payload['iat'] + expect(duration).to eq(1.hour) + end + end + end + + context 'with valid job token' do + let(:auth_token) { job_token } + + it 'responds with 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'with valid deploy token' do + let(:auth_token) { deploy_token.token } + + it 'responds with 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end +end + +RSpec.shared_examples 'conan check_credentials endpoint' do + it 'responds with a 200 OK with PAT' do + get api(url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'with job token' do + let(:auth_token) { job_token } + + it 'responds with a 200 OK with job token' do + get api(url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'with deploy token' do + let(:auth_token) { deploy_token.token } + + it 'responds with a 200 OK with job token' do + get api(url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + end + end + + it 'responds with a 401 Unauthorized when an invalid token is used' do + get api(url), headers: build_token_auth_header('invalid-token') + + expect(response).to have_gitlab_http_status(:unauthorized) + end +end + +RSpec.shared_examples 'rejects invalid recipe' do + context 'with invalid recipe path' do + let(:recipe_path) { '../../foo++../..' } + + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end +end + +RSpec.shared_examples 'rejects invalid file_name' do |invalid_file_name| + let(:file_name) { invalid_file_name } + + context 'with invalid file_name' do + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end +end + +RSpec.shared_examples 'rejects recipe for invalid project' do + context 'with invalid project' do + let(:recipe_path) { 'aa/bb/cc/dd' } + let(:project_id) { 9999 } + + it_behaves_like 'not found request' + end +end + +RSpec.shared_examples 'empty recipe for not found package' do + context 'with invalid recipe url' do + let(:recipe_path) do + 'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) } + end + + it 'returns not found' do + allow(::Packages::Conan::PackagePresenter).to receive(:new) + .with( + nil, + user, + project, + any_args + ).and_return(presenter) + allow(presenter).to receive(:recipe_snapshot) { {} } + allow(presenter).to receive(:package_snapshot) { {} } + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq("{}") + end + end +end + +RSpec.shared_examples 'not selecting a package with the wrong type' do + context 'with a nuget package with same name and version' do + let(:conan_username) { ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) } + let(:wrong_package) { create(:nuget_package, name: "wrong", version: '1.0.0', project: project) } + let(:recipe_path) { "#{wrong_package.name}/#{wrong_package.version}/#{conan_username}/foo" } + + it 'calls the presenter with a nil package' do + expect(::Packages::Conan::PackagePresenter).to receive(:new) + .with(nil, user, project, any_args) + + subject + end + end +end + +RSpec.shared_examples 'recipe download_urls' do + let(:recipe_path) { package.conan_recipe_path } + + it 'returns the download_urls for the recipe files' do + expected_response = { + 'conanfile.py' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py", + 'conanmanifest.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + + allow(presenter).to receive(:recipe_urls) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + + it_behaves_like 'not selecting a package with the wrong type' +end + +RSpec.shared_examples 'package download_urls' do + let(:recipe_path) { package.conan_recipe_path } + + it 'returns the download_urls for the package files' do + expected_response = { + 'conaninfo.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt", + 'conanmanifest.txt' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt", + 'conan_package.tgz' => "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz" + } + + allow(presenter).to receive(:package_urls) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + + it_behaves_like 'not selecting a package with the wrong type' +end + +RSpec.shared_examples 'rejects invalid upload_url params' do + context 'with unaccepted json format' do + let(:params) { %w[foo bar] } + + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end +end + +RSpec.shared_examples 'successful response when using Unicorn' do + context 'on Unicorn', :unicorn do + it 'returns successfully' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end +end + +RSpec.shared_examples 'recipe snapshot endpoint' do + subject { get api(url), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'empty recipe for not found package' + + context 'with existing package' do + it 'returns a hash of files with their md5 hashes' do + expected_response = { + 'conanfile.py' => 'md5hash1', + 'conanmanifest.txt' => 'md5hash2' + } + + allow(presenter).to receive(:recipe_snapshot) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + end +end + +RSpec.shared_examples 'package snapshot endpoint' do + subject { get api(url), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'empty recipe for not found package' + + context 'with existing package' do + it 'returns a hash of md5 values for the files' do + expected_response = { + 'conaninfo.txt' => "md5hash1", + 'conanmanifest.txt' => "md5hash2", + 'conan_package.tgz' => "md5hash3" + } + + allow(presenter).to receive(:package_snapshot) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + end +end + +RSpec.shared_examples 'recipe download_urls endpoint' do + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'recipe download_urls' +end + +RSpec.shared_examples 'package download_urls endpoint' do + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'package download_urls' +end + +RSpec.shared_examples 'recipe upload_urls endpoint' do + let(:recipe_path) { package.conan_recipe_path } + + let(:params) do + { 'conanfile.py': 24, + 'conanmanifest.txt': 123 } + end + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects invalid upload_url params' + it_behaves_like 'successful response when using Unicorn' + + it 'returns a set of upload urls for the files requested' do + subject + + expected_response = { + 'conanfile.py': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py", + 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + + expect(response.body).to eq(expected_response.to_json) + end + + context 'with conan_sources and conan_export files' do + let(:params) do + { 'conan_sources.tgz': 345, + 'conan_export.tgz': 234, + 'conanmanifest.txt': 123 } + end + + it 'returns upload urls for the additional files' do + subject + + expected_response = { + 'conan_sources.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_sources.tgz", + 'conan_export.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conan_export.tgz", + 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + + expect(response.body).to eq(expected_response.to_json) + end + end + + context 'with an invalid file' do + let(:params) do + { 'invalid_file.txt': 10, + 'conanmanifest.txt': 123 } + end + + it 'does not return the invalid file as an upload_url' do + subject + + expected_response = { + 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + + expect(response.body).to eq(expected_response.to_json) + end + end +end + +RSpec.shared_examples 'package upload_urls endpoint' do + let(:recipe_path) { package.conan_recipe_path } + + let(:params) do + { 'conaninfo.txt': 24, + 'conanmanifest.txt': 123, + 'conan_package.tgz': 523 } + end + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects invalid upload_url params' + it_behaves_like 'successful response when using Unicorn' + + it 'returns a set of upload urls for the files requested' do + expected_response = { + 'conaninfo.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt", + 'conanmanifest.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt", + 'conan_package.tgz': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz" + } + + subject + + expect(response.body).to eq(expected_response.to_json) + end + + context 'with invalid files' do + let(:params) do + { 'conaninfo.txt': 24, + 'invalid_file.txt': 10 } + end + + it 'returns upload urls only for the valid requested files' do + expected_response = { + 'conaninfo.txt': "#{url_prefix}/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt" + } + + subject + + expect(response.body).to eq(expected_response.to_json) + end + end +end + +RSpec.shared_examples 'delete package endpoint' do + let(:recipe_path) { package.conan_recipe_path } + + it_behaves_like 'rejects invalid recipe' + + it 'returns unauthorized for users without valid permission' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'with delete permissions' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'delete_package' + + it 'deletes a package' do + expect { subject }.to change { Packages::Package.count }.from(2).to(1) + end + end +end + +RSpec.shared_examples 'denies download with no token' do + context 'with no private token' do + let(:headers) { {} } + + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end +end + +RSpec.shared_examples 'a public project with packages' do + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end +end + +RSpec.shared_examples 'an internal project with packages' do + before do + project.team.truncate + project.update_column(:visibility_level, Gitlab::VisibilityLevel::INTERNAL) + end + + it_behaves_like 'denies download with no token' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end +end + +RSpec.shared_examples 'a private project with packages' do + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel::PRIVATE) + end + + it_behaves_like 'denies download with no token' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when not enough permissions' do + project.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end +end + +RSpec.shared_examples 'not found request' do + it 'returns not found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end +end + +RSpec.shared_examples 'recipe file download endpoint' do + it_behaves_like 'a public project with packages' + it_behaves_like 'an internal project with packages' + it_behaves_like 'a private project with packages' +end + +RSpec.shared_examples 'package file download endpoint' do + it_behaves_like 'a public project with packages' + it_behaves_like 'an internal project with packages' + it_behaves_like 'a private project with packages' + + context 'tracking the conan_package.tgz download' do + let(:package_file) { package.package_files.find_by(file_name: ::Packages::Conan::FileMetadatum::PACKAGE_BINARY) } + + it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'pull_package' + end +end + +RSpec.shared_examples 'project not found by recipe' do + let(:recipe_path) { 'not/package/for/project' } + + it_behaves_like 'not found request' +end + +RSpec.shared_examples 'project not found by project id' do + let(:project_id) { 99999 } + + it_behaves_like 'not found request' +end + +RSpec.shared_examples 'workhorse authorize endpoint' do + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack' + it_behaves_like 'workhorse authorization' +end + +RSpec.shared_examples 'workhorse recipe file upload endpoint' do + let(:file_name) { 'conanfile.py' } + let(:params) { { file: temp_file(file_name) } } + + subject do + workhorse_finalize( + url, + method: :put, + file_key: :file, + params: params, + headers: headers_with_token, + send_rewritten_field: true + ) + end + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects invalid file_name', 'conanfile.py.git%2fgit-upload-pack' + it_behaves_like 'uploads a package file' +end + +RSpec.shared_examples 'workhorse package file upload endpoint' do + let(:file_name) { 'conaninfo.txt' } + let(:params) { { file: temp_file(file_name) } } + + subject do + workhorse_finalize( + url, + method: :put, + file_key: :file, + params: params, + headers: headers_with_token, + send_rewritten_field: true + ) + end + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest' + it_behaves_like 'uploads a package file' + + context 'tracking the conan_package.tgz upload' do + let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY } + + it_behaves_like 'a gitlab tracking event', 'API::ConanPackages', 'push_package' + end +end + +RSpec.shared_examples 'uploads a package file' do + context 'file size above maximum limit' do + before do + params['file.size'] = project.actual_limits.conan_max_file_size + 1 + end + + it 'handles as a local file' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'with object storage disabled' do + context 'without a file from workhorse' do + let(:params) { { file: nil } } + + it 'rejects the request' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'with a file' do + it_behaves_like 'package workhorse uploads' + end + + context 'without a token' do + it 'rejects request without a token' do + headers_with_token.delete('HTTP_AUTHORIZATION') + + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when params from workhorse are correct' do + it 'creates package and stores package file' do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + + package_file = project.packages.last.package_files.reload.last + expect(package_file.file_name).to eq(params[:file].original_filename) + end + + it "doesn't attempt to migrate file to object storage" do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + subject + end + end + end + + context 'with object storage enabled' do + context 'and direct upload enabled' do + let!(:fog_connection) do + stub_package_file_object_storage(direct_upload: true) + end + + let(:tmp_object) do + fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang + key: "tmp/uploads/#{file_name}", + body: 'content' + ) + end + + let(:fog_file) { fog_to_uploaded_file(tmp_object) } + + ['123123', '../../123123'].each do |remote_id| + context "with invalid remote_id: #{remote_id}" do + let(:params) do + { + file: fog_file, + 'file.remote_id' => remote_id + } + end + + it 'responds with status 403' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + context 'with valid remote_id' do + let(:params) do + { + file: fog_file, + 'file.remote_id' => file_name + } + end + + it 'creates package and stores package file' do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + + package_file = project.packages.last.package_files.reload.last + expect(package_file.file_name).to eq(params[:file].original_filename) + expect(package_file.file.read).to eq('content') + end + end + end + + it_behaves_like 'background upload schedules a file migration' + end +end + +RSpec.shared_examples 'workhorse authorization' do + it 'authorizes posting package with a valid token' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + it 'rejects request without a valid token' do + headers_with_token['HTTP_AUTHORIZATION'] = 'foo' + + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'rejects request without a valid permission' do + project.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'rejects requests that bypassed gitlab-workhorse' do + headers_with_token.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'when using remote storage' do + context 'when direct upload is enabled' do + before do + stub_package_file_object_storage(enabled: true, direct_upload: true) + end + + it 'responds with status 200, location of package remote store and object details' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response).not_to have_key('TempPath') + expect(json_response['RemoteObject']).to have_key('ID') + expect(json_response['RemoteObject']).to have_key('GetURL') + expect(json_response['RemoteObject']).to have_key('StoreURL') + expect(json_response['RemoteObject']).to have_key('DeleteURL') + expect(json_response['RemoteObject']).not_to have_key('MultipartUpload') + end + end + + context 'when direct upload is disabled' do + before do + stub_package_file_object_storage(enabled: true, direct_upload: false) + end + + it 'handles as a local file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(::Packages::PackageFileUploader.workhorse_local_upload_path) + expect(json_response['RemoteObject']).to be_nil + end + end + end +end diff --git a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb index 8cbf11b6de1..f31cbcfdec1 100644 --- a/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true RSpec.shared_examples 'custom attributes endpoints' do |attributable_name| - let!(:custom_attribute1) { attributable.custom_attributes.create key: 'foo', value: 'foo' } - let!(:custom_attribute2) { attributable.custom_attributes.create key: 'bar', value: 'bar' } + let!(:custom_attribute1) { attributable.custom_attributes.create! key: 'foo', value: 'foo' } + let!(:custom_attribute2) { attributable.custom_attributes.create! key: 'bar', value: 'bar' } describe "GET /#{attributable_name} with custom attributes filter" do before do @@ -14,8 +14,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name| get api("/#{attributable_name}", user), params: { custom_attributes: { foo: 'foo', bar: 'bar' } } expect(response).to have_gitlab_http_status(:ok) - expect(json_response.size).to be 2 - expect(json_response.map { |r| r['id'] }).to contain_exactly attributable.id, other_attributable.id + expect(json_response.map { |r| r['id'] }).to include(attributable.id, other_attributable.id) end end @@ -40,7 +39,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name| get api("/#{attributable_name}", user), params: { with_custom_attributes: true } expect(response).to have_gitlab_http_status(:ok) - expect(json_response.size).to be 2 + expect(json_response).not_to be_empty expect(json_response.first).not_to include 'custom_attributes' end end @@ -50,16 +49,15 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name| get api("/#{attributable_name}", admin) expect(response).to have_gitlab_http_status(:ok) - expect(json_response.size).to be 2 + expect(json_response).not_to be_empty expect(json_response.first).not_to include 'custom_attributes' - expect(json_response.second).not_to include 'custom_attributes' end it 'includes custom attributes if requested' do get api("/#{attributable_name}", admin), params: { with_custom_attributes: true } expect(response).to have_gitlab_http_status(:ok) - expect(json_response.size).to be 2 + expect(json_response).not_to be_empty attributable_response = json_response.find { |r| r['id'] == attributable.id } other_attributable_response = json_response.find { |r| r['id'] == other_attributable.id } @@ -132,7 +130,7 @@ RSpec.shared_examples 'custom attributes endpoints' do |attributable_name| end context 'with an authorized user' do - it'returns a single custom attribute' do + it 'returns a single custom attribute' do get api("/#{attributable_name}/#{attributable.id}/custom_attributes/foo", admin) expect(response).to have_gitlab_http_status(:ok) diff --git a/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb index 48824a4b0d2..62dbac3fd4d 100644 --- a/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/mutations/snippets_shared_examples.rb @@ -8,3 +8,42 @@ RSpec.shared_examples 'when the snippet is not found' do it_behaves_like 'a mutation that returns top-level errors', errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] end + +RSpec.shared_examples 'snippet edit usage data counters' do + context 'when user is sessionless' do + it 'does not track usage data actions' do + expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_snippet_editor_edit_action) + + post_graphql_mutation(mutation, current_user: current_user) + end + end + + context 'when user is not sessionless' do + before do + session_id = Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') + session_hash = { 'warden.user.user.key' => [[current_user.id], current_user.encrypted_password[0, 29]] } + + Gitlab::Redis::SharedState.with do |redis| + redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash)) + end + + cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id + end + + it 'tracks usage data actions', :clean_gitlab_redis_shared_state do + expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_snippet_editor_edit_action) + + post_graphql_mutation(mutation) + end + + context 'when mutation result raises an error' do + it 'does not track usage data actions' do + mutation_vars[:title] = nil + + expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_snippet_editor_edit_action) + + post_graphql_mutation(mutation) + end + end + end +end diff --git a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb index fcdc594f258..6aac51a5903 100644 --- a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb @@ -175,7 +175,7 @@ RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member = context 'with object storage enabled' do let(:tmp_object) do - fog_connection.directories.new(key: 'packages').files.create( + fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang key: "tmp/uploads/#{file_name}", body: 'content' ) diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb index 6f4a0236b66..c9a33701161 100644 --- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb @@ -41,3 +41,88 @@ RSpec.shared_examples 'deploy token for package uploads' do end end end + +RSpec.shared_examples 'does not cause n^2 queries' do + it 'avoids N^2 database queries' do + # we create a package to set the baseline for expected queries from 1 package + create( + :npm_package, + name: "@#{project.root_namespace.path}/my-package", + project: project, + version: "0.0.1" + ) + + control = ActiveRecord::QueryRecorder.new do + get api(url) + end + + 5.times do |n| + create( + :npm_package, + name: "@#{project.root_namespace.path}/my-package", + project: project, + version: "#{n}.0.0" + ) + end + + expect do + get api(url) + end.not_to exceed_query_limit(control) + end +end + +RSpec.shared_examples 'job token for package GET requests' do + context 'with job token headers' do + let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + project.add_developer(user) + end + + context 'valid token' do + it_behaves_like 'returning response status', :success + end + + context 'invalid token' do + let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar') } + + it_behaves_like 'returning response status', :unauthorized + end + + context 'invalid user' do + let(:headers) { basic_auth_header('foo', job.token) } + + it_behaves_like 'returning response status', :unauthorized + end + end +end + +RSpec.shared_examples 'job token for package uploads' do + context 'with job token headers' do + let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token).merge(workhorse_header) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + project.add_developer(user) + end + + context 'valid token' do + it_behaves_like 'returning response status', :success + end + + context 'invalid token' do + let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar').merge(workhorse_header) } + + it_behaves_like 'returning response status', :unauthorized + end + + context 'invalid user' do + let(:headers) { basic_auth_header('foo', job.token).merge(workhorse_header) } + + it_behaves_like 'returning response status', :unauthorized + end + end +end diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb index 4954151b93b..715c494840e 100644 --- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -58,7 +58,7 @@ RSpec.shared_examples 'PyPi package creation' do |user_type, status, add_member context 'with object storage enabled' do let(:tmp_object) do - fog_connection.directories.new(key: 'packages').files.create( + fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang key: "tmp/uploads/#{file_name}", body: 'content' ) diff --git a/spec/support/shared_examples/requests/api/snippets_shared_examples.rb b/spec/support/shared_examples/requests/api/snippets_shared_examples.rb index cfbb84dd099..051367fbe96 100644 --- a/spec/support/shared_examples/requests/api/snippets_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/snippets_shared_examples.rb @@ -77,3 +77,142 @@ RSpec.shared_examples 'raw snippet files' do end end end + +RSpec.shared_examples 'snippet file updates' do + let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } } + let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } } + let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } } + let(:delete_action) { { action: 'delete', file_path: 'CONTRIBUTING.md' } } + let(:bad_file_path) { { action: 'create', file_path: '../../etc/passwd', content: 'bar' } } + let(:bad_previous_path) { { action: 'create', previous_path: '../../etc/passwd', file_path: 'CHANGELOG', content: 'bar' } } + let(:invalid_move) { { action: 'move', file_path: 'missing_previous_path.txt' } } + + context 'with various snippet file changes' do + using RSpec::Parameterized::TableSyntax + + where(:is_multi_file, :file_name, :content, :files, :status) do + true | nil | nil | [create_action] | :success + true | nil | nil | [update_action] | :success + true | nil | nil | [move_action] | :success + true | nil | nil | [delete_action] | :success + true | nil | nil | [create_action, update_action] | :success + true | 'foo.txt' | 'bar' | [create_action] | :bad_request + true | 'foo.txt' | 'bar' | nil | :bad_request + true | nil | nil | nil | :bad_request + true | 'foo.txt' | nil | [create_action] | :bad_request + true | nil | 'bar' | [create_action] | :bad_request + true | '' | nil | [create_action] | :bad_request + true | nil | '' | [create_action] | :bad_request + true | nil | nil | [bad_file_path] | :bad_request + true | nil | nil | [bad_previous_path] | :bad_request + true | nil | nil | [invalid_move] | :unprocessable_entity + + false | 'foo.txt' | 'bar' | nil | :success + false | 'foo.txt' | nil | nil | :success + false | nil | 'bar' | nil | :success + false | 'foo.txt' | 'bar' | [create_action] | :bad_request + false | nil | nil | nil | :bad_request + false | nil | '' | nil | :bad_request + false | nil | nil | [bad_file_path] | :bad_request + false | nil | nil | [bad_previous_path] | :bad_request + end + + with_them do + before do + allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(is_multi_file) + end + + it 'has the correct response' do + update_params = {}.tap do |params| + params[:files] = files if files + params[:file_name] = file_name if file_name + params[:content] = content if content + end + + update_snippet(params: update_params) + + expect(response).to have_gitlab_http_status(status) + end + end + + context 'when save fails due to a repository commit error' do + before do + allow_next_instance_of(Repository) do |instance| + allow(instance).to receive(:multi_action).and_raise(Gitlab::Git::CommitError) + end + + update_snippet(params: { files: [create_action] }) + end + + it 'returns a bad request response' do + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end +end + +RSpec.shared_examples 'snippet non-file updates' do + it 'updates a snippet non-file attributes' do + new_description = 'New description' + new_title = 'New title' + new_visibility = 'internal' + + update_snippet(params: { title: new_title, description: new_description, visibility: new_visibility }) + + snippet.reload + + aggregate_failures do + expect(response).to have_gitlab_http_status(:ok) + expect(snippet.description).to eq(new_description) + expect(snippet.visibility).to eq(new_visibility) + expect(snippet.title).to eq(new_title) + end + end +end + +RSpec.shared_examples 'snippet individual non-file updates' do + using RSpec::Parameterized::TableSyntax + + where(:attribute, :updated_value) do + :description | 'new description' + :title | 'new title' + :visibility | 'private' + end + + with_them do + it 'updates the attribute' do + params = { attribute => updated_value } + + expect { update_snippet(params: params) } + .to change { snippet.reload.send(attribute) }.to(updated_value) + end + end +end + +RSpec.shared_examples 'invalid snippet updates' do + it 'returns 404 for invalid snippet id' do + update_snippet(snippet_id: non_existing_record_id, params: { title: 'foo' }) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Snippet Not Found') + end + + it 'returns 400 for missing parameters' do + update_snippet + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns 400 if content is blank' do + update_snippet(params: { content: '' }) + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns 400 if title is blank' do + update_snippet(params: { title: '' }) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq 'title is empty' + end +end diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb index a17163328f4..84ef7723b9b 100644 --- a/spec/support/shared_examples/requests/snippet_shared_examples.rb +++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb @@ -2,6 +2,10 @@ RSpec.shared_examples 'update with repository actions' do context 'when the repository exists' do + before do + allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(false) + end + it 'commits the changes to the repository' do existing_blob = snippet.blobs.first new_file_name = existing_blob.path + '_new' diff --git a/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb b/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb index 1ef08de31a9..7608f1c7f8a 100644 --- a/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb +++ b/spec/support/shared_examples/serializers/diff_file_entity_shared_examples.rb @@ -57,16 +57,6 @@ RSpec.shared_examples 'diff file entity' do expect(subject).to include(:highlighted_diff_lines) end end - - context 'when the `single_mr_diff_view` feature is disabled' do - before do - stub_feature_flags(single_mr_diff_view: false) - end - - it 'contains both kinds of diffs' do - expect(subject).to include(:highlighted_diff_lines, :parallel_diff_lines) - end - end end end 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 a1354a8099b..1ae74979b7a 100644 --- a/spec/support/shared_examples/services/alert_management_shared_examples.rb +++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb @@ -39,3 +39,41 @@ RSpec.shared_examples 'adds an alert management alert event' do subject end end + +RSpec.shared_examples 'processes incident issues' do + let(:create_incident_service) { spy } + + before do + allow_any_instance_of(AlertManagement::Alert).to receive(:execute_services) + end + + it 'processes issues' do + expect(IncidentManagement::ProcessAlertWorker) + .to receive(:perform_async) + .with(nil, nil, kind_of(Integer)) + .once + + Sidekiq::Testing.inline! do + expect(subject).to be_success + end + end +end + +RSpec.shared_examples 'does not process incident issues' do + it 'does not process issues' do + expect(IncidentManagement::ProcessAlertWorker) + .not_to receive(:perform_async) + + expect(subject).to be_success + end +end + +RSpec.shared_examples 'does not process incident issues due to error' do |http_status:| + it 'does not process issues' do + expect(IncidentManagement::ProcessAlertWorker) + .not_to receive(:perform_async) + + expect(subject).to be_error + expect(subject.http_status).to eq(http_status) + end +end diff --git a/spec/support/shared_examples/services/common_system_notes_shared_examples.rb b/spec/support/shared_examples/services/common_system_notes_shared_examples.rb index 20856b05de6..5b95a5753a1 100644 --- a/spec/support/shared_examples/services/common_system_notes_shared_examples.rb +++ b/spec/support/shared_examples/services/common_system_notes_shared_examples.rb @@ -5,7 +5,7 @@ RSpec.shared_examples 'system note creation' do |update_params, note_text| before do issuable.assign_attributes(update_params) - issuable.save + issuable.save! end it 'creates 1 system note with the correct content' do diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb new file mode 100644 index 00000000000..d6e79931df5 --- /dev/null +++ b/spec/support/shared_examples/services/incident_shared_examples.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# This shared_example requires the following variables: +# - issue (required) +# +# Usage: +# +# it_behaves_like 'incident issue' do +# let(:issue) { ... } +# end +# +# include_examples 'incident issue' +RSpec.shared_examples 'incident issue' do + let(:label_properties) { attributes_for(:label, :incident) } + + it 'has incident as issue type' do + expect(issue.issue_type).to eq('incident') + end + + 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) +# +# Usage: +# +# it_behaves_like 'not an incident issue' do +# let(:issue) { ... } +# end +# +# include_examples 'not an incident issue' +RSpec.shared_examples 'not an incident issue' do + let(:label_properties) { attributes_for(:label, :incident) } + + it 'has not incident as issue type' do + expect(issue.issue_type).not_to eq('incident') + end + + it 'has not an incident label' do + expect(issue.labels).not_to include(have_attributes(label_properties)) + end +end diff --git a/spec/support/shared_examples/services/issuable_shared_examples.rb b/spec/support/shared_examples/services/issuable_shared_examples.rb index 9eb66e33513..47c7a1e7356 100644 --- a/spec/support/shared_examples/services/issuable_shared_examples.rb +++ b/spec/support/shared_examples/services/issuable_shared_examples.rb @@ -8,37 +8,6 @@ RSpec.shared_examples 'cache counters invalidator' do end end -RSpec.shared_examples 'system notes for milestones' do - def update_issuable(opts) - issuable = try(:issue) || try(:merge_request) - described_class.new(project, user, opts).execute(issuable) - end - - context 'group milestones' do - let(:group) { create(:group) } - let(:group_milestone) { create(:milestone, group: group) } - - before do - project.update(namespace: group) - create(:group_member, group: group, user: user) - end - - it 'creates a system note' do - expect do - update_issuable(milestone: group_milestone) - end.to change { Note.system.count }.by(1) - end - end - - context 'project milestones' do - it 'creates a system note' do - expect do - update_issuable(milestone: create(:milestone, project: project)) - end.to change { Note.system.count }.by(1) - end - end -end - RSpec.shared_examples 'updating a single task' do def update_issuable(opts) issuable = try(:issue) || try(:merge_request) diff --git a/spec/support/shared_examples/services/merge_request_shared_examples.rb b/spec/support/shared_examples/services/merge_request_shared_examples.rb new file mode 100644 index 00000000000..a7032640217 --- /dev/null +++ b/spec/support/shared_examples/services/merge_request_shared_examples.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'reviewer_ids filter' do + context 'filter_reviewer' do + let(:opts) { super().merge(reviewer_ids_param) } + + context 'without reviewer_ids' do + let(:reviewer_ids_param) { {} } + + it 'contains no reviewer_ids' do + expect(execute.reviewers).to eq [] + end + end + + context 'with reviewer_ids' do + let(:reviewer_ids_param) { { reviewer_ids: [reviewer1.id, reviewer2.id, reviewer3.id] } } + + let(:reviewer1) { create(:user) } + let(:reviewer2) { create(:user) } + let(:reviewer3) { create(:user) } + + context 'when the current user can admin the merge_request' do + context 'when merge_request_reviewer feature is enabled' do + before do + stub_feature_flags(merge_request_reviewer: true) + end + + context 'with reviewers who can read the merge_request' do + before do + project.add_developer(reviewer1) + project.add_developer(reviewer2) + end + + it 'contains reviewers who can read the merge_request' do + expect(execute.reviewers).to contain_exactly(reviewer1, reviewer2) + end + end + end + + context 'when merge_request_reviewer feature is disabled' do + before do + stub_feature_flags(merge_request_reviewer: false) + end + + it 'contains no reviewers' do + expect(execute.reviewers).to eq [] + end + end + end + + context 'when the current_user cannot admin the merge_request' do + before do + project.add_developer(user) + end + + it 'contains no reviewers' do + expect(execute.reviewers).to eq [] + end + end + end + end +end diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb index 45a4c2bb151..7fd59c3d963 100644 --- a/spec/support/shared_examples/services/packages_shared_examples.rb +++ b/spec/support/shared_examples/services/packages_shared_examples.rb @@ -14,6 +14,14 @@ RSpec.shared_examples 'assigns build to package' do end end +RSpec.shared_examples 'assigns the package creator' do + it 'assigns the package creator' do + subject + + expect(package.creator).to eq user + end +end + RSpec.shared_examples 'returns packages' do |container_type, user_type| context "for #{user_type}" do before do @@ -161,6 +169,7 @@ RSpec.shared_examples 'filters on each package_type' do |is_project: false| let_it_be(:package4) { create(:nuget_package, project: project) } let_it_be(:package5) { create(:pypi_package, project: project) } let_it_be(:package6) { create(:composer_package, project: project) } + let_it_be(:package7) { create(:generic_package, project: project) } Packages::Package.package_types.keys.each do |package_type| context "for package type #{package_type}" do diff --git a/spec/support/shared_examples/services/snippets_shared_examples.rb b/spec/support/shared_examples/services/snippets_shared_examples.rb index 51a4a8b1cd9..4a08c0d4365 100644 --- a/spec/support/shared_examples/services/snippets_shared_examples.rb +++ b/spec/support/shared_examples/services/snippets_shared_examples.rb @@ -40,3 +40,20 @@ RSpec.shared_examples 'snippets spam check is performed' do end end end + +shared_examples 'invalid params error response' do + before do + allow_next_instance_of(described_class) do |service| + allow(service).to receive(:valid_params?).and_return false + end + end + + it 'responds to errors appropriately' do + response = subject + + aggregate_failures do + expect(response).to be_error + expect(response.http_status).to eq 422 + end + end +end diff --git a/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb b/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb index db1b50fdf3c..ffdd0c36cfc 100644 --- a/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb +++ b/spec/support/shared_examples/services/wiki_pages/destroy_service_shared_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples 'WikiPages::DestroyService#execute' do |container_type| - let(:container) { create(container_type) } + let(:container) { create(container_type) } # rubocop:disable Rails/SaveBang let(:user) { create(:user) } let(:page) { create(:wiki_page) } @@ -32,9 +32,19 @@ RSpec.shared_examples 'WikiPages::DestroyService#execute' do |container_type| ) end - it 'does not increment the delete count if the deletion failed' do - counter = Gitlab::UsageDataCounters::WikiPageCounter + context 'when the deletion fails' do + before do + expect(page).to receive(:delete).and_return(false) + end + + it 'returns an error response' do + response = service.execute(page) + expect(response).to be_error + end - expect { service.execute(nil) }.not_to change { counter.read(:delete) } + it 'does not increment the delete count if the deletion failed' do + counter = Gitlab::UsageDataCounters::WikiPageCounter + expect { service.execute(page) }.not_to change { counter.read(:delete) } + end end end diff --git a/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb b/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb index 0191a6dfbc9..fd10dd4367e 100644 --- a/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb +++ b/spec/support/shared_examples/services/wiki_pages/update_service_shared_examples.rb @@ -19,8 +19,10 @@ RSpec.shared_examples 'WikiPages::UpdateService#execute' do |container_type| subject(:service) { described_class.new(container: container, current_user: user, params: opts) } it 'updates the wiki page' do - updated_page = service.execute(page) + response = service.execute(page) + updated_page = response.payload[:page] + expect(response).to be_success expect(updated_page).to be_valid expect(updated_page.message).to eq(opts[:message]) expect(updated_page.content).to eq(opts[:content]) @@ -81,7 +83,11 @@ RSpec.shared_examples 'WikiPages::UpdateService#execute' do |container_type| end it 'reports the error' do - expect(service.execute(page)).to be_invalid + response = service.execute(page) + page = response.payload[:page] + + expect(response).to be_error + expect(page).to be_invalid .and have_attributes(errors: be_present) end end diff --git a/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb b/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb index f143cbc7165..5a9a3dfc2d2 100644 --- a/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb +++ b/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb @@ -63,7 +63,7 @@ RSpec.shared_examples 'uploads migration worker' do if success > 0 it 'outputs the reports' do - expect(Rails.logger).to receive(:info).with(%r{Migrated #{success}/#{total} files}) + expect(Gitlab::AppLogger).to receive(:info).with(%r{Migrated #{success}/#{total} files}) perform(uploads) end @@ -71,7 +71,7 @@ RSpec.shared_examples 'uploads migration worker' do if failures > 0 it 'outputs upload failures' do - expect(Rails.logger).to receive(:warn).with(/Error .* I am a teapot/) + expect(Gitlab::AppLogger).to receive(:warn).with(/Error .* I am a teapot/) perform(uploads) end |