diff options
Diffstat (limited to 'spec/support')
113 files changed, 3565 insertions, 700 deletions
diff --git a/spec/support/factory_default.rb b/spec/support/factory_default.rb new file mode 100644 index 00000000000..e116c28f132 --- /dev/null +++ b/spec/support/factory_default.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.after do |ex| + TestProf::FactoryDefault.reset unless ex.metadata[:factory_default] == :keep + end + + config.after(:all) do + TestProf::FactoryDefault.reset + end +end diff --git a/spec/support/forgery_protection.rb b/spec/support/forgery_protection.rb index 1d6ea013292..d12e99b17c4 100644 --- a/spec/support/forgery_protection.rb +++ b/spec/support/forgery_protection.rb @@ -8,7 +8,7 @@ module ForgeryProtection ActionController::Base.allow_forgery_protection = false end - module_function :with_forgery_protection + module_function :with_forgery_protection # rubocop: disable Style/AccessModifierDeclarations end RSpec.configure do |config| diff --git a/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml b/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml index 4134660e4b9..c4f3c3aace2 100644 --- a/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml +++ b/spec/support/gitlab_stubs/gitlab_ci_for_sast.yml @@ -4,6 +4,7 @@ include: variables: SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/gitlab-org/security-products/analyzers2" SAST_EXCLUDED_PATHS: "spec, executables" + SAST_DEFAULT_ANALYZERS: "bandit, gosec" stages: - our_custom_security_stage @@ -11,3 +12,4 @@ sast: stage: our_custom_security_stage variables: SEARCH_MAX_DEPTH: 8 + SAST_BRAKEMAN_LEVEL: 2 diff --git a/spec/support/helpers/ci/source_pipeline_helpers.rb b/spec/support/helpers/ci/source_pipeline_helpers.rb new file mode 100644 index 00000000000..b99f499cc16 --- /dev/null +++ b/spec/support/helpers/ci/source_pipeline_helpers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + module SourcePipelineHelpers + def create_source_pipeline(upstream, downstream) + create(:ci_sources_pipeline, + source_job: create(:ci_build, pipeline: upstream), + source_project: upstream.project, + pipeline: downstream, + project: downstream.project) + end + end +end diff --git a/spec/support/helpers/dns_helpers.rb b/spec/support/helpers/dns_helpers.rb index 29be4da6902..1795b0a9ac3 100644 --- a/spec/support/helpers/dns_helpers.rb +++ b/spec/support/helpers/dns_helpers.rb @@ -25,6 +25,6 @@ module DnsHelpers def permit_local_dns! local_addresses = /\A(127|10)\.0\.0\.\d{1,3}|(192\.168|172\.16)\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|localhost\z/i allow(Addrinfo).to receive(:getaddrinfo).with(local_addresses, anything, nil, :STREAM).and_call_original - allow(Addrinfo).to receive(:getaddrinfo).with(local_addresses, anything, nil, :STREAM, anything, anything).and_call_original + allow(Addrinfo).to receive(:getaddrinfo).with(local_addresses, anything, nil, :STREAM, anything, anything, any_args).and_call_original end end diff --git a/spec/support/helpers/docs_screenshot_helpers.rb b/spec/support/helpers/docs_screenshot_helpers.rb new file mode 100644 index 00000000000..aa3aad0a740 --- /dev/null +++ b/spec/support/helpers/docs_screenshot_helpers.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'mini_magick' + +module DocsScreenshotHelpers + extend ActiveSupport::Concern + + def set_crop_data(element, padding) + @crop_element = element + @crop_padding = padding + end + + def crop_image_screenshot(path) + element_rect = @crop_element.evaluate_script("this.getBoundingClientRect()") + + width = element_rect['width'] + (@crop_padding * 2) + height = element_rect['height'] + (@crop_padding * 2) + + x = element_rect['x'] - @crop_padding + y = element_rect['y'] - @crop_padding + + image = MiniMagick::Image.new(path) + image.crop "#{width}x#{height}+#{x}+#{y}" + end + + included do |base| + after do |example| + filename = "#{example.description}.png" + path = File.expand_path(filename, 'doc/') + page.save_screenshot(path) + + if @crop_element + crop_image_screenshot(path) + set_crop_data(nil, nil) + end + end + end +end diff --git a/spec/support/helpers/fake_u2f_device.rb b/spec/support/helpers/fake_u2f_device.rb index f765b277175..2ed1222ebd3 100644 --- a/spec/support/helpers/fake_u2f_device.rb +++ b/spec/support/helpers/fake_u2f_device.rb @@ -3,9 +3,10 @@ class FakeU2fDevice attr_reader :name - def initialize(page, name) + def initialize(page, name, device = nil) @page = page @name = name + @u2f_device = device end def respond_to_u2f_registration diff --git a/spec/support/helpers/fake_webauthn_device.rb b/spec/support/helpers/fake_webauthn_device.rb new file mode 100644 index 00000000000..d2c2f7d6bf3 --- /dev/null +++ b/spec/support/helpers/fake_webauthn_device.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +require 'webauthn/fake_client' + +class FakeWebauthnDevice + attr_reader :name + + def initialize(page, name, device = nil) + @page = page + @name = name + @webauthn_device = device + end + + def respond_to_webauthn_registration + app_id = @page.evaluate_script('gon.webauthn.app_id') + challenge = @page.evaluate_script('gon.webauthn.options.challenge') + + json_response = webauthn_device(app_id).create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang + @page.execute_script <<~JS + var result = #{json_response}; + result.getClientExtensionResults = () => ({}); + navigator.credentials.create = function(_) { + return Promise.resolve(result); + }; + JS + end + + def respond_to_webauthn_authentication + app_id = @page.evaluate_script('JSON.parse(gon.webauthn.options).extensions.appid') + challenge = @page.evaluate_script('JSON.parse(gon.webauthn.options).challenge') + + begin + json_response = webauthn_device(app_id).get(challenge: challenge).to_json + + rescue RuntimeError + # A runtime error is raised from fake webauthn if no credentials have been registered yet. + # To be able to test non registered devices, credentials are created ad-hoc + webauthn_device(app_id).create # rubocop:disable Rails/SaveBang + json_response = webauthn_device(app_id).get(challenge: challenge).to_json + end + + @page.execute_script <<~JS + var result = #{json_response}; + result.getClientExtensionResults = () => ({}); + navigator.credentials.get = function(_) { + return Promise.resolve(result); + }; + JS + @page.click_link('Try again?', href: false) + end + + def fake_webauthn_authentication + @page.execute_script <<~JS + const mockResponse = { + type: 'public-key', + id: '', + rawId: '', + response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' }, + getClientExtensionResults: () => {}, + }; + window.gl.resolveWebauthn(mockResponse); + JS + end + + def add_credential(app_id, credential_id, credential_key) + credentials = { URI.parse(app_id).host => { credential_id => { credential_key: credential_key, sign_count: 0 } } } + webauthn_device(app_id).send(:authenticator).instance_variable_set(:@credentials, credentials) + end + + private + + def webauthn_device(app_id) + @webauthn_device ||= WebAuthn::FakeClient.new(app_id) + end +end diff --git a/spec/support/helpers/feature_flag_helpers.rb b/spec/support/helpers/feature_flag_helpers.rb new file mode 100644 index 00000000000..93cd915879b --- /dev/null +++ b/spec/support/helpers/feature_flag_helpers.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module FeatureFlagHelpers + def create_flag(project, name, active = true, description: nil, version: Operations::FeatureFlag.versions['legacy_flag']) + create(:operations_feature_flag, name: name, active: active, version: version, + description: description, project: project) + end + + def create_scope(feature_flag, environment_scope, active = true, strategies = [{ name: "default", parameters: {} }]) + create(:operations_feature_flag_scope, + feature_flag: feature_flag, + environment_scope: environment_scope, + active: active, + strategies: strategies) + end + + def within_feature_flag_row(index) + within ".gl-responsive-table-row:nth-child(#{index + 1})" do + yield + end + end + + def within_feature_flag_scopes + within '.js-feature-flag-environments' do + yield + end + end + + def within_scope_row(index) + within ".gl-responsive-table-row:nth-child(#{index + 1})" do + yield + end + end + + def within_strategy_row(index) + within ".feature-flags-form > fieldset > div[data-testid='feature-flag-strategies'] > div:nth-child(#{index})" do + yield + end + end + + def within_environment_spec + within '.table-section:nth-child(1)' do + yield + end + end + + def within_status + within '.table-section:nth-child(2)' do + yield + end + end + + def within_delete + within '.table-section:nth-child(4)' do + yield + end + end + + def edit_feature_flag_button + find('.js-feature-flag-edit-button') + end + + def delete_strategy_button + find("button[data-testid='delete-strategy-button']") + end + + def add_linked_issue_button + find('.js-issue-count-badge-add-button') + end + + def remove_linked_issue_button + find('.js-issue-item-remove-button') + end + + def status_toggle_button + find('[data-testid="feature-flag-status-toggle"] button') + end + + def expect_status_toggle_button_to_be_checked + expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button.is-checked') + end + + def expect_status_toggle_button_not_to_be_checked + expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button:not(.is-checked)') + end + + def expect_status_toggle_button_to_be_disabled + expect(page).to have_css('[data-testid="feature-flag-status-toggle"] button.is-disabled') + end + + def expect_user_to_see_feature_flags_index_page + expect(page).to have_text('Feature Flags') + expect(page).to have_text('Lists') + end +end diff --git a/spec/support/helpers/features/editor_lite_spec_helpers.rb b/spec/support/helpers/features/editor_lite_spec_helpers.rb new file mode 100644 index 00000000000..0a67e753379 --- /dev/null +++ b/spec/support/helpers/features/editor_lite_spec_helpers.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# These helpers help you interact within the Editor Lite (single-file editor, snippets, etc.). +# +module Spec + module Support + module Helpers + module Features + module EditorLiteSpecHelpers + include ActionView::Helpers::JavaScriptHelper + + def editor_set_value(value) + editor = find('.monaco-editor') + uri = editor['data-uri'] + + execute_script("monaco.editor.getModel('#{uri}').setValue('#{escape_javascript(value)}')") + end + + def editor_get_value + editor = find('.monaco-editor') + uri = editor['data-uri'] + + evaluate_script("monaco.editor.getModel('#{uri}').getValue()") + end + end + end + end + end +end diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb new file mode 100644 index 00000000000..0d46918b05c --- /dev/null +++ b/spec/support/helpers/features/releases_helpers.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# These helpers fill fields on the "New Release" and +# "Edit Release" pages. They use the keyboard to navigate +# from one field to the next and assume that when +# they are called, the field to be filled out is already focused. +# +# Usage: +# describe "..." do +# include Spec::Support::Helpers::Features::ReleasesHelpers +# ... +# +# fill_tag_name("v1.0") +# select_create_from("my-feature-branch") +# +module Spec + module Support + module Helpers + module Features + module ReleasesHelpers + # Returns the element that currently has keyboard focus. + # Reminder that this returns a Selenium::WebDriver::Element + # _not_ a Capybara::Node::Element + def focused_element + page.driver.browser.switch_to.active_element + end + + def fill_tag_name(tag_name, and_tab: true) + expect(focused_element).to eq(find_field('Tag name').native) + + focused_element.send_keys(tag_name) + + focused_element.send_keys(:tab) if and_tab + end + + def select_create_from(branch_name, and_tab: true) + expect(focused_element).to eq(find('[data-testid="create-from-field"] button').native) + + focused_element.send_keys(:enter) + + # Wait for the dropdown to be rendered + page.find('.ref-selector .dropdown-menu') + + # Pressing Enter in the search box shouldn't submit the form + focused_element.send_keys(branch_name, :enter) + + # Wait for the search to return + page.find('.ref-selector .dropdown-item', text: branch_name, match: :first) + + focused_element.send_keys(:arrow_down, :enter) + + focused_element.send_keys(:tab) if and_tab + end + + def fill_release_title(release_title, and_tab: true) + expect(focused_element).to eq(find_field('Release title').native) + + focused_element.send_keys(release_title) + + focused_element.send_keys(:tab) if and_tab + end + + def select_milestone(milestone_title, and_tab: true) + expect(focused_element).to eq(find('[data-testid="milestones-field"] button').native) + + focused_element.send_keys(:enter) + + # Wait for the dropdown to be rendered + page.find('.project-milestone-combobox .dropdown-menu') + + # Clear any existing input + focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) } + + # Pressing Enter in the search box shouldn't submit the form + focused_element.send_keys(milestone_title, :enter) + + # Wait for the search to return + page.find('.project-milestone-combobox .dropdown-item', text: milestone_title, match: :first) + + focused_element.send_keys(:arrow_down, :arrow_down, :enter) + + focused_element.send_keys(:tab) if and_tab + end + + def fill_release_notes(release_notes, and_tab: true) + expect(focused_element).to eq(find_field('Release notes').native) + + focused_element.send_keys(release_notes) + + # Tab past the links at the bottom of the editor + focused_element.send_keys(:tab, :tab, :tab) if and_tab + end + + def fill_asset_link(link, and_tab: true) + expect(focused_element['id']).to start_with('asset-url-') + + focused_element.send_keys(link[:url], :tab, link[:title], :tab, link[:type]) + + # Tab past the "Remove asset link" button + focused_element.send_keys(:tab, :tab) if and_tab + end + + # Click "Add another link" and tab back to the beginning of the new row + def add_another_asset_link + expect(focused_element).to eq(find_button('Add another link').native) + + focused_element.send_keys(:enter, + [:shift, :tab], + [:shift, :tab], + [:shift, :tab], + [:shift, :tab]) + end + end + end + end + end +end diff --git a/spec/support/helpers/features/snippet_helpers.rb b/spec/support/helpers/features/snippet_helpers.rb new file mode 100644 index 00000000000..c01d179770c --- /dev/null +++ b/spec/support/helpers/features/snippet_helpers.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# These helpers help you interact within the Editor Lite (single-file editor, snippets, etc.). +# +module Spec + module Support + module Helpers + module Features + module SnippetSpecHelpers + include ActionView::Helpers::JavaScriptHelper + include Spec::Support::Helpers::Features::EditorLiteSpecHelpers + + def snippet_get_first_blob_path + page.find_field(snippet_blob_path_field, match: :first).value + end + + def snippet_get_first_blob_value + page.find(snippet_blob_content_selector, match: :first) + end + + def snippet_description_value + page.find_field(snippet_description_field).value + end + + def snippet_fill_in_form(title:, content:, description: '') + # fill_in snippet_title_field, with: title + # editor_set_value(content) + fill_in snippet_title_field, with: title + + if description + # Click placeholder first to expand full description field + description_field.click + fill_in snippet_description_field, with: description + end + + page.within('.file-editor') do + el = find('.inputarea') + el.send_keys content + end + end + + private + + def description_field + find('.js-description-input').find('input,textarea') + end + end + end + end + end +end diff --git a/spec/support/helpers/features/two_factor_helpers.rb b/spec/support/helpers/features/two_factor_helpers.rb new file mode 100644 index 00000000000..08a7665201f --- /dev/null +++ b/spec/support/helpers/features/two_factor_helpers.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +# These helpers allow you to manage and register +# U2F and WebAuthn devices +# +# Usage: +# describe "..." do +# include Spec::Support::Helpers::Features::TwoFactorHelpers +# ... +# +# manage_two_factor_authentication +# +module Spec + module Support + module Helpers + module Features + module TwoFactorHelpers + def manage_two_factor_authentication + click_on 'Manage two-factor authentication' + expect(page).to have_content("Set up new device") + wait_for_requests + end + + def register_u2f_device(u2f_device = nil, name: 'My device') + u2f_device ||= FakeU2fDevice.new(page, name) + u2f_device.respond_to_u2f_registration + click_on 'Set up new device' + expect(page).to have_content('Your device was successfully set up') + fill_in "Pick a name", with: name + click_on 'Register device' + u2f_device + end + + # Registers webauthn device via UI + def register_webauthn_device(webauthn_device = nil, name: 'My device') + webauthn_device ||= FakeWebauthnDevice.new(page, name) + webauthn_device.respond_to_webauthn_registration + click_on 'Set up new device' + expect(page).to have_content('Your device was successfully set up') + fill_in 'Pick a name', with: name + click_on 'Register device' + webauthn_device + end + + # Adds webauthn device directly via database + def add_webauthn_device(app_id, user, fake_device = nil, name: 'My device') + fake_device ||= WebAuthn::FakeClient.new(app_id) + + options_for_create = WebAuthn::Credential.options_for_create( + user: { id: user.webauthn_xid, name: user.username }, + authenticator_selection: { user_verification: 'discouraged' }, + rp: { name: 'GitLab' } + ) + challenge = options_for_create.challenge + + device_response = fake_device.create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang + device_registration_params = { device_response: device_response, + name: name } + + Webauthn::RegisterService.new( + user, device_registration_params, challenge).execute + FakeWebauthnDevice.new(page, name, fake_device) + end + + def assert_fallback_ui(page) + expect(page).to have_button('Verify code') + expect(page).to have_css('#user_otp_attempt') + expect(page).not_to have_link('Sign in via 2FA code') + expect(page).not_to have_css("#js-authenticate-token-2fa") + end + end + end + end + end +end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 87525734490..5635ba3df05 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -241,6 +241,39 @@ module GraphqlHelpers post_graphql(mutation.query, current_user: current_user, variables: mutation.variables) end + def post_graphql_mutation_with_uploads(mutation, current_user: nil) + file_paths = file_paths_in_mutation(mutation) + params = mutation_to_apollo_uploads_param(mutation, files: file_paths) + + workhorse_post_with_file(api('/', current_user, version: 'graphql'), + params: params, + file_key: '1' + ) + end + + def file_paths_in_mutation(mutation) + paths = [] + find_uploads(paths, [], mutation.variables) + + paths + end + + # Depth first search for UploadedFile values + def find_uploads(paths, path, value) + case value + when Rack::Test::UploadedFile + paths << path + when Hash + value.each do |k, v| + find_uploads(paths, path + [k], v) + end + when Array + value.each_with_index do |v, i| + find_uploads(paths, path + [i], v) + end + end + end + # this implements GraphQL multipart request v2 # https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2 # this is simplified and do not support file deduplication diff --git a/spec/support/helpers/jira_service_helper.rb b/spec/support/helpers/jira_service_helper.rb index 4895bc3ba15..698490c8c92 100644 --- a/spec/support/helpers/jira_service_helper.rb +++ b/spec/support/helpers/jira_service_helper.rb @@ -78,8 +78,7 @@ module JiraServiceHelper end def stub_jira_service_test - WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/serverInfo') - .to_return(body: { url: 'http://url' }.to_json) + WebMock.stub_request(:get, /serverInfo/).to_return(body: { url: 'http://url' }.to_json) end def stub_jira_urls(issue_id) diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb index 1118cfcf7ac..e21d4497cda 100644 --- a/spec/support/helpers/login_helpers.rb +++ b/spec/support/helpers/login_helpers.rb @@ -111,6 +111,11 @@ module LoginHelpers FakeU2fDevice.new(page, nil).fake_u2f_authentication end + def fake_successful_webauthn_authentication + allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true) + FakeWebauthnDevice.new(page, nil).fake_webauthn_authentication + end + def mock_auth_hash_with_saml_xml(provider, uid, email, saml_response) response_object = { document: saml_xml(saml_response) } mock_auth_hash(provider, uid, email, response_object: response_object) diff --git a/spec/support/helpers/markdown_feature.rb b/spec/support/helpers/markdown_feature.rb index 40e0d4413e2..0cb2863dc2c 100644 --- a/spec/support/helpers/markdown_feature.rb +++ b/spec/support/helpers/markdown_feature.rb @@ -87,6 +87,10 @@ class MarkdownFeature @group_milestone ||= create(:milestone, name: 'group-milestone', group: group) end + def alert + @alert ||= create(:alert_management_alert, project: project) + end + # Cross-references ----------------------------------------------------------- def xproject @@ -125,6 +129,10 @@ class MarkdownFeature @xmilestone ||= create(:milestone, project: xproject) end + def xalert + @xalert ||= create(:alert_management_alert, project: xproject) + end + def urls Gitlab::Routing.url_helpers end diff --git a/spec/support/helpers/metrics_dashboard_helpers.rb b/spec/support/helpers/metrics_dashboard_helpers.rb index 7168079fead..a384e44f428 100644 --- a/spec/support/helpers/metrics_dashboard_helpers.rb +++ b/spec/support/helpers/metrics_dashboard_helpers.rb @@ -47,7 +47,7 @@ module MetricsDashboardHelpers end def business_metric_title - PrometheusMetricEnums.group_details[:business][:group_title] + Enums::PrometheusMetric.group_details[:business][:group_title] end def self_monitoring_dashboard_path diff --git a/spec/support/helpers/multipart_helpers.rb b/spec/support/helpers/multipart_helpers.rb new file mode 100644 index 00000000000..043cb6e1420 --- /dev/null +++ b/spec/support/helpers/multipart_helpers.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module MultipartHelpers + include WorkhorseHelpers + + def post_env(rewritten_fields:, params:, secret:, issuer:) + token = JWT.encode({ 'iss' => issuer, 'rewritten_fields' => rewritten_fields }, secret, 'HS256') + Rack::MockRequest.env_for( + '/', + method: 'post', + params: params, + described_class::RACK_ENV_KEY => token + ) + end + + # This function assumes a `mode` variable to be set + def upload_parameters_for(filepath: nil, key: nil, filename: 'filename', remote_id: 'remote_id') + result = { + "#{key}.name" => filename, + "#{key}.type" => "application/octet-stream", + "#{key}.sha256" => "1234567890" + } + + case mode + when :local + result["#{key}.path"] = filepath + when :remote + result["#{key}.remote_id"] = remote_id + result["#{key}.size"] = 3.megabytes + else + raise ArgumentError, "can't handle #{mode} mode" + end + + return result if ::Feature.disabled?(:upload_middleware_jwt_params_handler) + + # the HandlerForJWTParams expects a jwt token with the upload parameters + # *without* the "#{key}." prefix + result.deep_transform_keys! { |k| k.remove("#{key}.") } + { + "#{key}.gitlab-workhorse-upload" => jwt_token('upload' => result) + } + end + + # This function assumes a `mode` variable to be set + def rewritten_fields_hash(hash) + if mode == :remote + # For remote uploads, workhorse still submits rewritten_fields, + # but all the values are empty strings. + hash.keys.each { |k| hash[k] = '' } + end + + hash + end + + def expect_uploaded_files(uploaded_file_expectations) + expect(app).to receive(:call) do |env| + Array.wrap(uploaded_file_expectations).each do |expectation| + file = get_params(env).dig(*expectation[:params_path]) + expect_uploaded_file(file, expectation) + end + end + end + + # This function assumes a `mode` variable to be set + def expect_uploaded_file(file, expectation) + expect(file).to be_a(::UploadedFile) + expect(file.original_filename).to eq(expectation[:original_filename]) + expect(file.sha256).to eq('1234567890') + + case mode + when :local + expect(file.path).to eq(File.realpath(expectation[:filepath])) + expect(file.remote_id).to be_nil + expect(file.size).to eq(expectation[:size]) + when :remote + expect(file.remote_id).to eq(expectation[:remote_id]) + expect(file.path).to be_nil + expect(file.size).to eq(3.megabytes) + else + raise ArgumentError, "can't handle #{mode} mode" + end + end + + # Rails doesn't combine the GET/POST parameters in + # ActionDispatch::HTTP::Parameters if action_dispatch.request.parameters is set: + # https://github.com/rails/rails/blob/aea6423f013ca48f7704c70deadf2cd6ac7d70a1/actionpack/lib/action_dispatch/http/parameters.rb#L41 + def get_params(env) + req = ActionDispatch::Request.new(env) + req.GET.merge(req.POST) + end +end diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb index c7aa2ffe536..11e67ba394c 100644 --- a/spec/support/helpers/navbar_structure_helper.rb +++ b/spec/support/helpers/navbar_structure_helper.rb @@ -4,13 +4,13 @@ module NavbarStructureHelper def insert_after_nav_item(before_nav_item_name, new_nav_item:) expect(structure).to include(a_hash_including(nav_item: before_nav_item_name)) - index = structure.find_index { |h| h[:nav_item] == before_nav_item_name } + index = structure.find_index { |h| h[:nav_item] == before_nav_item_name if h } structure.insert(index + 1, new_nav_item) end def insert_after_sub_nav_item(before_sub_nav_item_name, within:, new_sub_nav_item_name:) expect(structure).to include(a_hash_including(nav_item: within)) - hash = structure.find { |h| h[:nav_item] == within } + hash = structure.find { |h| h[:nav_item] == within if h } expect(hash).to have_key(:nav_sub_items) expect(hash[:nav_sub_items]).to include(before_sub_nav_item_name) diff --git a/spec/support/helpers/next_found_instance_of.rb b/spec/support/helpers/next_found_instance_of.rb new file mode 100644 index 00000000000..ff34fcdd1d3 --- /dev/null +++ b/spec/support/helpers/next_found_instance_of.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module NextFoundInstanceOf + ERROR_MESSAGE = 'NextFoundInstanceOf mock helpers can only be used with ActiveRecord targets' + + def expect_next_found_instance_of(klass) + check_if_active_record!(klass) + + stub_allocate(expect(klass)) do |expectation| + yield(expectation) + end + end + + def allow_next_found_instance_of(klass) + check_if_active_record!(klass) + + stub_allocate(allow(klass)) do |allowance| + yield(allowance) + end + end + + private + + def check_if_active_record!(klass) + raise ArgumentError.new(ERROR_MESSAGE) unless klass < ActiveRecord::Base + end + + def stub_allocate(target) + target.to receive(:allocate).and_wrap_original do |method| + method.call.tap { |allocation| yield(allocation) } + end + end +end diff --git a/spec/support/helpers/project_forks_helper.rb b/spec/support/helpers/project_forks_helper.rb index a32e39e52c8..4b4285f251e 100644 --- a/spec/support/helpers/project_forks_helper.rb +++ b/spec/support/helpers/project_forks_helper.rb @@ -17,14 +17,26 @@ module ProjectForksHelper project.add_developer(user) end - unless params[:namespace] || params[:namespace_id] + unless params[:namespace] params[:namespace] = create(:group) params[:namespace].add_owner(user) end + namespace = params[:namespace] + create_repository = params.delete(:repository) + + unless params[:target_project] || params[:using_service] + target_level = [project.visibility_level, namespace.visibility_level].min + visibility_level = Gitlab::VisibilityLevel.closest_allowed_level(target_level) + + params[:target_project] = + create(:project, + (:repository if create_repository), + visibility_level: visibility_level, creator: user, namespace: namespace) + end + service = Projects::ForkService.new(project, user, params) - create_repository = params.delete(:repository) # Avoid creating a repository unless create_repository allow(RepositoryForkWorker).to receive(:perform_async).and_return(true) diff --git a/spec/support/helpers/repo_helpers.rb b/spec/support/helpers/repo_helpers.rb index 7741c805b37..bbba58d60d6 100644 --- a/spec/support/helpers/repo_helpers.rb +++ b/spec/support/helpers/repo_helpers.rb @@ -125,7 +125,7 @@ eos end def create_file_in_repo( - project, start_branch, branch_name, filename, content, + project, start_branch, branch_name, filename, content, commit_message: 'Add new content') Files::CreateService.new( project, diff --git a/spec/support/helpers/snowplow_helpers.rb b/spec/support/helpers/snowplow_helpers.rb new file mode 100644 index 00000000000..83a5b7e48bc --- /dev/null +++ b/spec/support/helpers/snowplow_helpers.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module SnowplowHelpers + # Asserts call for one snowplow event from `Gitlab::Tracking#event`. + # + # @param [Hash] + # + # Examples: + # + # describe '#show', :snowplow do + # it 'tracks snowplow events' do + # get :show + # + # expect_snowplow_event(category: 'Experiment', action: 'start') + # end + # end + # + # describe '#create', :snowplow do + # it 'tracks snowplow events' do + # post :create + # + # expect_snowplow_event( + # category: 'Experiment', + # action: 'created', + # ) + # expect_snowplow_event( + # category: 'Experiment', + # action: 'accepted', + # property: 'property', + # label: 'label' + # ) + # end + # end + def expect_snowplow_event(category:, action:, **kwargs) + expect(Gitlab::Tracking).to have_received(:event) + .with(category, action, **kwargs).at_least(:once) + end + + # Asserts that no call to `Gitlab::Tracking#event` was made. + # + # Example: + # + # describe '#show', :snowplow do + # it 'does not track any snowplow events' do + # get :show + # + # expect_no_snowplow_event + # end + # end + def expect_no_snowplow_event + expect(Gitlab::Tracking).not_to have_received(:event) + end +end diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index 8a52a614821..476b7d34ee5 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -14,7 +14,7 @@ module StubObjectStorage end def stub_object_storage_uploader( - config:, + config:, uploader:, remote_directory:, enabled: true, diff --git a/spec/support/helpers/stubbed_feature.rb b/spec/support/helpers/stubbed_feature.rb index e78efcf6b75..d4e9af7a031 100644 --- a/spec/support/helpers/stubbed_feature.rb +++ b/spec/support/helpers/stubbed_feature.rb @@ -37,10 +37,7 @@ module StubbedFeature # We do `m.call` as we want to validate the execution of method arguments # and a feature flag state if it is not persisted unless Feature.persisted_name?(args.first) - # TODO: this is hack to support `promo_feature_available?` - # We enable all feature flags by default unless they are `promo_` - # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/218667 - feature_flag = true unless args.first.to_s.start_with?('promo_') + feature_flag = true end feature_flag diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 7dae960410d..641ed24207e 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -247,8 +247,9 @@ module TestEnv 'GitLab Workhorse', install_dir: workhorse_dir, version: Gitlab::Workhorse.version, - task: "gitlab:workhorse:install[#{install_workhorse_args}]" - ) + task: "gitlab:workhorse:install[#{install_workhorse_args}]") do + Gitlab::SetupHelper::Workhorse.create_configuration(workhorse_dir, nil) + end end def workhorse_dir @@ -259,16 +260,22 @@ module TestEnv host = "[#{host}]" if host.include?(':') listen_addr = [host, port].join(':') + config_path = Gitlab::SetupHelper::Workhorse.get_config_path(workhorse_dir) + + # This should be set up in setup_workhorse, but since + # component_needs_update? only checks that versions are consistent, + # we need to ensure the config file exists. This line can be removed + # later after a new Workhorse version is updated. + Gitlab::SetupHelper::Workhorse.create_configuration(workhorse_dir, nil) unless File.exist?(config_path) + workhorse_pid = spawn( + { 'PATH' => "#{ENV['PATH']}:#{workhorse_dir}" }, File.join(workhorse_dir, 'gitlab-workhorse'), '-authSocket', upstream, '-documentRoot', Rails.root.join('public').to_s, '-listenAddr', listen_addr, '-secretPath', Gitlab::Workhorse.secret_path.to_s, - # TODO: Needed for workhorse + redis features. - # https://gitlab.com/gitlab-org/gitlab/-/issues/209245 - # - # '-config', '', + '-config', config_path, '-logFile', 'log/workhorse-test.log', '-logFormat', 'structured', '-developmentMode' # to serve assets and rich error messages diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index fab775dd404..d92fcdc2d4a 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -88,6 +88,8 @@ module UsageDataHelpers projects_jira_active projects_jira_server_active projects_jira_cloud_active + projects_jira_dvcs_cloud_active + projects_jira_dvcs_server_active projects_slack_active projects_slack_slash_commands_active projects_custom_issue_tracker_active @@ -136,6 +138,7 @@ module UsageDataHelpers USAGE_DATA_KEYS = %i( active_user_count counts + counts_monthly recorded_at edition version @@ -160,6 +163,7 @@ module UsageDataHelpers web_ide_clientside_preview_enabled ingress_modsecurity_enabled object_store + topology ).freeze def stub_usage_data_connections @@ -220,17 +224,8 @@ module UsageDataHelpers ) end - def expect_prometheus_api_to(*receive_matchers) - expect_next_instance_of(Gitlab::PrometheusClient) do |client| - receive_matchers.each { |m| expect(client).to m } - end - end - - def allow_prometheus_queries - allow_next_instance_of(Gitlab::PrometheusClient) do |client| - allow(client).to receive(:aggregate).and_return({}) - allow(client).to receive(:query).and_return({}) - end + def expect_prometheus_client_to(*receive_matchers) + receive_matchers.each { |m| expect(prometheus_client).to m } end def for_defined_days_back(days: [29, 2]) diff --git a/spec/support/helpers/wiki_helpers.rb b/spec/support/helpers/wiki_helpers.rb index ae0d53d1297..e59c6bde264 100644 --- a/spec/support/helpers/wiki_helpers.rb +++ b/spec/support/helpers/wiki_helpers.rb @@ -3,6 +3,11 @@ module WikiHelpers extend self + def stub_group_wikis(enabled) + stub_feature_flags(group_wikis: enabled) + stub_licensed_features(group_wikis: enabled) + end + def wait_for_svg_to_be_loaded(example = nil) # Ensure the SVG is loaded first before clicking the button find('.svg-content .js-lazy-loaded') if example.nil? || example.metadata.key?(:js) diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb index f16b6c1e910..7e95f49aea2 100644 --- a/spec/support/helpers/workhorse_helpers.rb +++ b/spec/support/helpers/workhorse_helpers.rb @@ -3,6 +3,8 @@ module WorkhorseHelpers extend self + UPLOAD_PARAM_NAMES = %w[name size path remote_id sha256 type].freeze + def workhorse_send_data @_workhorse_send_data ||= begin header = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] @@ -59,6 +61,7 @@ module WorkhorseHelpers file = workhorse_params.delete(key) rewritten_fields[key] = file.path if file workhorse_params = workhorse_disk_accelerated_file_params(key, file).merge(workhorse_params) + workhorse_params = workhorse_params.merge(jwt_file_upload_param(key: key, params: workhorse_params)) end headers = if send_rewritten_field @@ -74,8 +77,19 @@ module WorkhorseHelpers private - def jwt_token(data = {}) - JWT.encode({ 'iss' => 'gitlab-workhorse' }.merge(data), Gitlab::Workhorse.secret, 'HS256') + def jwt_file_upload_param(key:, params:) + upload_params = UPLOAD_PARAM_NAMES.map do |file_upload_param| + [file_upload_param, params["#{key}.#{file_upload_param}"]] + end + upload_params = upload_params.to_h.compact + + return {} if upload_params.empty? + + { "#{key}.gitlab-workhorse-upload" => jwt_token('upload' => upload_params) } + end + + def jwt_token(data = {}, issuer: 'gitlab-workhorse', secret: Gitlab::Workhorse.secret, algorithm: 'HS256') + JWT.encode({ 'iss' => issuer }.merge(data), secret, algorithm) end def workhorse_rewritten_fields_header(fields) diff --git a/spec/support/import_export/configuration_helper.rb b/spec/support/import_export/configuration_helper.rb index 6f67b0f3dd7..b731ee626c0 100644 --- a/spec/support/import_export/configuration_helper.rb +++ b/spec/support/import_export/configuration_helper.rb @@ -44,8 +44,8 @@ module ConfigurationHelper import_export_config = config_hash(config) excluded_attributes = import_export_config[:excluded_attributes][relation_name.to_sym] included_attributes = import_export_config[:included_attributes][relation_name.to_sym] - attributes = attributes - Gitlab::Json.parse(excluded_attributes.to_json) if excluded_attributes - attributes = attributes & Gitlab::Json.parse(included_attributes.to_json) if included_attributes + attributes -= Gitlab::Json.parse(excluded_attributes.to_json) if excluded_attributes + attributes &= Gitlab::Json.parse(included_attributes.to_json) if included_attributes attributes end diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index 103019d8dd8..6e75fa58700 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -174,6 +174,15 @@ module MarkdownMatchers end end + # AlertReferenceFilter + matcher :reference_alerts do + set_default_markdown_messages + + match do |actual| + expect(actual).to have_selector('a.gfm.gfm-alert', count: 5) + end + end + # TaskListFilter matcher :parse_task_lists do set_default_markdown_messages diff --git a/spec/support/shared_contexts/features/file_uploads_shared_context.rb b/spec/support/shared_contexts/features/file_uploads_shared_context.rb new file mode 100644 index 00000000000..972d25e81d2 --- /dev/null +++ b/spec/support/shared_contexts/features/file_uploads_shared_context.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.shared_context 'file upload requests helpers' do + def capybara_url(path) + "http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}#{path}" + end +end diff --git a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb index fc8f9d2f407..54022aeb494 100644 --- a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb @@ -5,4 +5,5 @@ RSpec.shared_context 'UsersFinder#execute filter by project context' do let_it_be(:blocked_user) { create(:user, :blocked, username: 'notsorandom') } let_it_be(:external_user) { create(:user, :external) } let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } + let_it_be(:internal_user) { User.alert_bot } end diff --git a/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb b/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb index f1554ea8e9f..ec5bea34e8b 100644 --- a/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb +++ b/spec/support/shared_contexts/lib/gitlab/middleware/multipart_shared_contexts.rb @@ -1,42 +1,88 @@ # frozen_string_literal: true -RSpec.shared_context 'multipart middleware context' do - let(:app) { double(:app) } - let(:middleware) { described_class.new(app) } - let(:original_filename) { 'filename' } - - # Rails 5 doesn't combine the GET/POST parameters in - # ActionDispatch::HTTP::Parameters if action_dispatch.request.parameters is set: - # https://github.com/rails/rails/blob/aea6423f013ca48f7704c70deadf2cd6ac7d70a1/actionpack/lib/action_dispatch/http/parameters.rb#L41 - def get_params(env) - req = ActionDispatch::Request.new(env) - req.GET.merge(req.POST) +# This context provides one temporary file for the multipart spec +# +# Here are the available variables: +# - uploaded_file +# - uploaded_filepath +# - filename +# - remote_id +RSpec.shared_context 'with one temporary file for multipart' do |within_tmp_sub_dir: false| + let(:uploaded_filepath) { uploaded_file.path } + + around do |example| + Tempfile.open('uploaded_file2') do |tempfile| + @uploaded_file = tempfile + @filename = 'test_file.png' + @remote_id = 'remote_id' + + example.run + end end - def post_env(rewritten_fields, params, secret, issuer) - token = JWT.encode({ 'iss' => issuer, 'rewritten_fields' => rewritten_fields }, secret, 'HS256') - Rack::MockRequest.env_for( - '/', - method: 'post', - params: params, - described_class::RACK_ENV_KEY => token - ) + attr_reader :uploaded_file, :filename, :remote_id +end + +# This context provides two temporary files for the multipart spec +# +# Here are the available variables: +# - uploaded_file +# - uploaded_filepath +# - filename +# - remote_id +# - tmp_sub_dir (only when using within_tmp_sub_dir: true) +# - uploaded_file2 +# - uploaded_filepath2 +# - filename2 +# - remote_id2 +RSpec.shared_context 'with two temporary files for multipart' do + include_context 'with one temporary file for multipart' + + let(:uploaded_filepath2) { uploaded_file2.path } + + around do |example| + Tempfile.open('uploaded_file2') do |tempfile| + @uploaded_file2 = tempfile + @filename2 = 'test_file2.png' + @remote_id2 = 'remote_id2' + + example.run + end end - def with_tmp_dir(uploads_sub_dir, storage_path = '') - Dir.mktmpdir do |dir| - upload_dir = File.join(dir, storage_path, uploads_sub_dir) - FileUtils.mkdir_p(upload_dir) + attr_reader :uploaded_file2, :filename2, :remote_id2 +end + +# This context provides three temporary files for the multipart spec +# +# Here are the available variables: +# - uploaded_file +# - uploaded_filepath +# - filename +# - remote_id +# - tmp_sub_dir (only when using within_tmp_sub_dir: true) +# - uploaded_file2 +# - uploaded_filepath2 +# - filename2 +# - remote_id2 +# - uploaded_file3 +# - uploaded_filepath3 +# - filename3 +# - remote_id3 +RSpec.shared_context 'with three temporary files for multipart' do + include_context 'with two temporary files for multipart' - allow(Rails).to receive(:root).and_return(dir) - allow(Dir).to receive(:tmpdir).and_return(File.join(Dir.tmpdir, 'tmpsubdir')) - allow(GitlabUploader).to receive(:root).and_return(File.join(dir, storage_path)) + let(:uploaded_filepath3) { uploaded_file3.path } - Tempfile.open('top-level', upload_dir) do |tempfile| - env = post_env({ 'file' => tempfile.path }, { 'file.name' => original_filename, 'file.path' => tempfile.path }, Gitlab::Workhorse.secret, 'gitlab-workhorse') + around do |example| + Tempfile.open('uploaded_file3') do |tempfile| + @uploaded_file3 = tempfile + @filename3 = 'test_file3.png' + @remote_id3 = 'remote_id3' - yield dir, env - end + example.run end end + + attr_reader :uploaded_file3, :filename3, :remote_id3 end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index e276a54224b..747358fc1e0 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -119,10 +119,10 @@ RSpec.shared_context 'group navbar structure' do nav_item: _('Settings'), nav_sub_items: [ _('General'), + _('Integrations'), _('Projects'), _('Repository'), _('CI / CD'), - _('Integrations'), _('Webhooks'), _('Audit Events') ] @@ -138,6 +138,13 @@ RSpec.shared_context 'group navbar structure' do } end + let(:push_rules_nav_item) do + { + nav_item: _('Push Rules'), + nav_sub_items: [] + } + end + let(:structure) do [ { @@ -160,6 +167,7 @@ RSpec.shared_context 'group navbar structure' do nav_item: _('Merge Requests'), nav_sub_items: [] }, + (push_rules_nav_item if Gitlab.ee?), { nav_item: _('Kubernetes'), nav_sub_items: [] diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 5339fa003b9..3016494ac8d 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -1,30 +1,36 @@ # frozen_string_literal: true RSpec.shared_context 'ProjectPolicy context' do + let_it_be(:anonymous) { nil } let_it_be(:guest) { create(:user) } let_it_be(:reporter) { create(:user) } let_it_be(:developer) { create(:user) } let_it_be(:maintainer) { create(:user) } let_it_be(:owner) { create(:user) } let_it_be(:admin) { create(:admin) } - let(:project) { create(:project, :public, namespace: owner.namespace) } + let_it_be(:non_member) { create(:user) } + let_it_be_with_refind(:private_project) { create(:project, :private, namespace: owner.namespace) } + let_it_be_with_refind(:internal_project) { create(:project, :internal, namespace: owner.namespace) } + let_it_be_with_refind(:public_project) { create(:project, :public, namespace: owner.namespace) } let(:base_guest_permissions) do %i[ - read_project read_board read_list read_wiki read_issue - read_project_for_iids read_issue_iid read_label - read_milestone read_snippet read_project_member read_note - create_project create_issue create_note upload_file create_merge_request_in - award_emoji + award_emoji create_issue create_merge_request_in create_note + create_project read_board read_issue read_issue_iid read_issue_link + read_label read_list read_milestone read_note read_project + read_project_for_iids read_project_member read_release read_snippet + read_wiki upload_file ] end let(:base_reporter_permissions) do %i[ - download_code fork_project create_snippet update_issue - admin_issue admin_label admin_list read_commit_status read_build - read_container_image read_pipeline read_environment read_deployment - read_merge_request download_wiki_code read_sentry_issue read_prometheus + admin_issue admin_issue_link admin_label admin_list create_snippet + download_code download_wiki_code fork_project metrics_dashboard + read_build read_commit_status read_confidential_issues + read_container_image read_deployment read_environment read_merge_request + read_metrics_dashboard_annotation read_pipeline read_prometheus + read_sentry_issue update_issue ] end @@ -34,37 +40,42 @@ RSpec.shared_context 'ProjectPolicy context' do let(:developer_permissions) do %i[ - admin_milestone admin_merge_request update_merge_request create_commit_status - update_commit_status create_build update_build create_pipeline - update_pipeline create_merge_request_from create_wiki push_code - resolve_note create_container_image update_container_image - create_environment create_deployment update_deployment create_release update_release - update_environment daily_statistics + admin_merge_request admin_milestone admin_tag create_build + create_commit_status create_container_image create_deployment + create_environment create_merge_request_from + create_metrics_dashboard_annotation create_pipeline create_release + create_wiki daily_statistics delete_metrics_dashboard_annotation + destroy_container_image push_code read_pod_logs read_terraform_state + resolve_note update_build update_commit_status update_container_image + update_deployment update_environment update_merge_request + update_metrics_dashboard_annotation update_pipeline update_release ] end let(:base_maintainer_permissions) do %i[ - push_to_delete_protected_branch update_snippet - admin_snippet admin_project_member admin_note admin_wiki admin_project - admin_commit_status admin_build admin_container_image - admin_pipeline admin_environment admin_deployment destroy_release add_cluster + add_cluster admin_build admin_commit_status admin_container_image + admin_deployment admin_environment admin_note admin_pipeline + admin_project admin_project_member admin_snippet admin_terraform_state + admin_wiki create_deploy_token destroy_deploy_token destroy_release + push_to_delete_protected_branch read_deploy_token update_snippet ] end let(:public_permissions) do %i[ - download_code fork_project read_commit_status read_pipeline - read_container_image build_download_code build_read_container_image - download_wiki_code read_release + build_download_code build_read_container_image download_code + download_wiki_code fork_project read_commit_status read_container_image + read_pipeline read_release ] end let(:base_owner_permissions) do %i[ - change_namespace change_visibility_level rename_project remove_project - archive_project remove_fork_project destroy_merge_request destroy_issue - set_issue_iid set_issue_created_at set_issue_updated_at set_note_created_at + archive_project change_namespace change_visibility_level destroy_issue + destroy_merge_request remove_fork_project remove_project rename_project + set_issue_created_at set_issue_iid set_issue_updated_at + set_note_created_at ] end @@ -79,10 +90,12 @@ RSpec.shared_context 'ProjectPolicy context' do let(:maintainer_permissions) { base_maintainer_permissions + additional_maintainer_permissions } let(:owner_permissions) { base_owner_permissions + additional_owner_permissions } - before do - project.add_guest(guest) - project.add_maintainer(maintainer) - project.add_developer(developer) - project.add_reporter(reporter) + before_all do + [private_project, internal_project, public_project].each do |project| + project.add_guest(guest) + project.add_reporter(reporter) + project.add_developer(developer) + project.add_maintainer(maintainer) + end end end diff --git a/spec/support/shared_contexts/project_service_jira_context.rb b/spec/support/shared_contexts/project_service_jira_context.rb index 4ca5c99323a..8e01de70846 100644 --- a/spec/support/shared_contexts/project_service_jira_context.rb +++ b/spec/support/shared_contexts/project_service_jira_context.rb @@ -5,7 +5,7 @@ RSpec.shared_context 'project service Jira context' do let(:test_url) { 'http://jira.example.com/rest/api/2/serverInfo' } def fill_form(disable: false) - click_active_toggle if disable + click_active_checkbox if disable fill_in 'service_url', with: url fill_in 'service_username', with: 'username' diff --git a/spec/support/shared_contexts/project_service_shared_context.rb b/spec/support/shared_contexts/project_service_shared_context.rb index a72d4901b72..b4b9ab456e0 100644 --- a/spec/support/shared_contexts/project_service_shared_context.rb +++ b/spec/support/shared_contexts/project_service_shared_context.rb @@ -18,19 +18,27 @@ RSpec.shared_context 'project service activation' do click_link(name) end - def click_active_toggle - find('input[name="service[active]"] + button').click + def click_active_checkbox + find('input[name="service[active]"]').click + end + + def click_save_integration + click_button('Save changes') end def click_test_integration - click_button('Test settings and save changes') + click_link('Test settings') end - def click_test_then_save_integration + def click_test_then_save_integration(expect_test_to_fail: true) click_test_integration - expect(page).to have_content('Test failed.') + if expect_test_to_fail + expect(page).to have_content('Connection failed.') + else + expect(page).to have_content('Connection successful.') + end - click_link('Save anyway') + click_save_integration end end diff --git a/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb new file mode 100644 index 00000000000..580ebf00dcb --- /dev/null +++ b/spec/support/shared_contexts/requests/api/conan_packages_shared_context.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.shared_context 'conan api setup' do + include PackagesManagerApiSpecHelpers + include HttpBasicAuthHelpers + + let(:package) { create(:conan_package) } + let_it_be(:personal_access_token) { create(:personal_access_token) } + let_it_be(:user) { personal_access_token.user } + let_it_be(:base_secret) { SecureRandom.base64(64) } + let_it_be(:job) { create(:ci_build, :running, user: user) } + let_it_be(:job_token) { job.token } + let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } + + let(:project) { package.project } + let(:auth_token) { personal_access_token.token } + let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } + + let(:headers) do + { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('foo', auth_token) } + end + + let(:jwt_secret) do + OpenSSL::HMAC.hexdigest( + OpenSSL::Digest::SHA256.new, + base_secret, + Gitlab::ConanToken::HMAC_KEY + ) + end + + before do + project.add_developer(user) + allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret) + end +end + +RSpec.shared_context 'conan recipe endpoints' do + include PackagesManagerApiSpecHelpers + include HttpBasicAuthHelpers + + let(:jwt) { build_jwt(personal_access_token) } + let(:headers) { build_token_auth_header(jwt.encoded) } + let(:conan_package_reference) { '123456789' } + let(:presenter) { double('::Packages::Conan::PackagePresenter') } + + before do + allow(::Packages::Conan::PackagePresenter).to receive(:new) + .with(package, user, package.project, any_args) + .and_return(presenter) + end +end + +RSpec.shared_context 'conan file download endpoints' do + include PackagesManagerApiSpecHelpers + include HttpBasicAuthHelpers + + let(:jwt) { build_jwt(personal_access_token) } + let(:headers) { build_token_auth_header(jwt.encoded) } + let(:recipe_path) { package.conan_recipe_path } + let(:package_file) { package.package_files.find_by(file_name: 'conaninfo.txt') } + let(:recipe_file) { package.package_files.find_by(file_name: 'conanfile.py') } + let(:metadata) { package_file.conan_file_metadatum } +end + +RSpec.shared_context 'conan file upload endpoints' do + include PackagesManagerApiSpecHelpers + include WorkhorseHelpers + include HttpBasicAuthHelpers + + let(:jwt) { build_jwt(personal_access_token) } + let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let(:headers_with_token) { build_token_auth_header(jwt.encoded).merge(workhorse_header) } + let(:recipe_path) { "foo/bar/#{project.full_path.tr('/', '+')}/baz"} +end diff --git a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb new file mode 100644 index 00000000000..fce78957eba --- /dev/null +++ b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.shared_context 'group_group_link' do + let(:shared_with_group) { create(:group) } + let(:shared_group) { create(:group) } + + let!(:group_group_link) do + create( + :group_group_link, + { + shared_group: shared_group, + shared_with_group: shared_with_group, + expires_at: '2020-05-12' + } + ) + end +end diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb index 899b43ade01..2bd516a2339 100644 --- a/spec/support/shared_contexts/services_shared_context.rb +++ b/spec/support/shared_contexts/services_shared_context.rb @@ -2,6 +2,8 @@ Service.available_services_names.each do |service| RSpec.shared_context service do + include JiraServiceHelper if service == 'jira' + let(:dashed_service) { service.dasherize } let(:service_method) { "#{service}_service".to_sym } let(:service_klass) { "#{service}_service".classify.constantize } @@ -39,6 +41,7 @@ Service.available_services_names.each do |service| before do enable_license_for_service(service) + stub_jira_service_test if service == 'jira' end def initialize_service(service) 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 diff --git a/spec/support/snowplow.rb b/spec/support/snowplow.rb new file mode 100644 index 00000000000..58812b8f4e6 --- /dev/null +++ b/spec/support/snowplow.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.before(:each, :snowplow) do + # Using a high buffer size to not cause early flushes + buffer_size = 100 + # WebMock is set up to allow requests to `localhost` + host = 'localhost' + + allow(Gitlab::Tracking) + .to receive(:emitter) + .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size)) + + stub_application_setting(snowplow_enabled: true) + + allow(Gitlab::Tracking).to receive(:event).and_call_original + end + + config.after(:each, :snowplow) do + Gitlab::Tracking.send(:snowplow).flush + end +end diff --git a/spec/support/test_reports/test_reports_helper.rb b/spec/support/test_reports/test_reports_helper.rb index 6ba50c83b25..ad9ecb6f460 100644 --- a/spec/support/test_reports/test_reports_helper.rb +++ b/spec/support/test_reports/test_reports_helper.rb @@ -10,12 +10,12 @@ module TestReportsHelper status: Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS) end - def create_test_case_rspec_failed(name = 'test_spec') + def create_test_case_rspec_failed(name = 'test_spec', execution_time = 2.22) Gitlab::Ci::Reports::TestCase.new( name: 'Test#sum when a is 1 and b is 3 returns summary', classname: "spec.#{name}", file: './spec/test_spec.rb', - execution_time: 2.22, + execution_time: execution_time, system_output: sample_rspec_failed_message, status: Gitlab::Ci::Reports::TestCase::STATUS_FAILED) end |