diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 11:59:07 +0000 |
commit | 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch) | |
tree | 544930fb309b30317ae9797a9683768705d664c4 /spec/support | |
parent | 4b1de649d0168371549608993deac953eb692019 (diff) | |
download | gitlab-ce-8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca.tar.gz |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'spec/support')
89 files changed, 3682 insertions, 379 deletions
diff --git a/spec/support/atlassian/jira_connect/schemata.rb b/spec/support/atlassian/jira_connect/schemata.rb new file mode 100644 index 00000000000..91f8fe0bb41 --- /dev/null +++ b/spec/support/atlassian/jira_connect/schemata.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Atlassian + module Schemata + def self.build_info + { + 'type' => 'object', + 'required' => %w(schemaVersion pipelineId buildNumber updateSequenceNumber displayName url state issueKeys testInfo references), + 'properties' => { + 'schemaVersion' => { 'type' => 'string', 'pattern' => '1.0' }, + 'pipelineId' => { 'type' => 'string' }, + 'buildNumber' => { 'type' => 'integer' }, + 'updateSequenceNumber' => { 'type' => 'integer' }, + 'displayName' => { 'type' => 'string' }, + 'url' => { 'type' => 'string' }, + 'state' => { + 'type' => 'string', + 'pattern' => '(pending|in_progress|successful|failed|cancelled)' + }, + 'issueKeys' => { + 'type' => 'array', + 'items' => { 'type' => 'string' }, + 'minItems' => 1 + }, + 'testInfo' => { + 'type' => 'object', + 'required' => %w(totalNumber numberPassed numberFailed numberSkipped), + 'properties' => { + 'totalNumber' => { 'type' => 'integer' }, + 'numberFailed' => { 'type' => 'integer' }, + 'numberPassed' => { 'type' => 'integer' }, + 'numberSkipped' => { 'type' => 'integer' } + } + }, + 'references' => { + 'type' => 'array', + 'items' => { + 'type' => 'object', + 'required' => %w(commit ref), + 'properties' => { + 'commit' => { + 'type' => 'object', + 'required' => %w(id repositoryUri), + 'properties' => { + 'id' => { 'type' => 'string' }, + 'repositoryUri' => { 'type' => 'string' } + } + }, + 'ref' => { + 'type' => 'object', + 'required' => %w(name uri), + 'properties' => { + 'name' => { 'type' => 'string' }, + 'uri' => { 'type' => 'string' } + } + } + } + } + } + } + } + end + + def self.build_info_payload + { + 'type' => 'object', + 'required' => %w(providerMetadata builds), + 'properties' => { + 'providerMetadata' => provider_metadata, + 'builds' => { 'type' => 'array', 'items' => build_info } + } + } + end + + def self.provider_metadata + { + 'type' => 'object', + 'required' => %w(product), + 'properties' => { 'product' => { 'type' => 'string' } } + } + end + end +end diff --git a/spec/support/caching.rb b/spec/support/caching.rb index 883d531550a..11e4f534971 100644 --- a/spec/support/caching.rb +++ b/spec/support/caching.rb @@ -25,7 +25,7 @@ RSpec.configure do |config| original_null_store = Rails.cache caching_config_hash = Gitlab::Redis::Cache.params caching_config_hash[:namespace] = Gitlab::Redis::Cache::CACHE_NAMESPACE - Rails.cache = ActiveSupport::Cache::RedisCacheStore.new(caching_config_hash) + Rails.cache = ActiveSupport::Cache::RedisCacheStore.new(**caching_config_hash) redis_cache_cleanup! diff --git a/spec/support/gitlab_experiment.rb b/spec/support/gitlab_experiment.rb new file mode 100644 index 00000000000..1f283e4f06c --- /dev/null +++ b/spec/support/gitlab_experiment.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Disable all caching for experiments in tests. +Gitlab::Experiment::Configuration.cache = nil diff --git a/spec/support/gitlab_stubs/gitlab_ci_includes.yml b/spec/support/gitlab_stubs/gitlab_ci_includes.yml new file mode 100644 index 00000000000..e74773ce23e --- /dev/null +++ b/spec/support/gitlab_stubs/gitlab_ci_includes.yml @@ -0,0 +1,19 @@ +rspec 0 1: + stage: build + script: 'rake spec' + needs: [] + +rspec 0 2: + stage: build + script: 'rake spec' + needs: [] + +spinach: + stage: build + script: 'rake spinach' + needs: [] + +docker: + stage: test + script: 'curl http://dockerhub/URL' + needs: [spinach, rspec 0 1] diff --git a/spec/support/graphql/arguments.rb b/spec/support/graphql/arguments.rb new file mode 100644 index 00000000000..d8c334c2ca4 --- /dev/null +++ b/spec/support/graphql/arguments.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Graphql + class Arguments + delegate :blank?, :empty?, to: :to_h + + def initialize(values) + @values = values.compact + end + + def to_h + @values + end + + def ==(other) + to_h == other&.to_h + end + + alias_method :eql, :== + + def to_s + return '' if empty? + + @values.map do |name, value| + value_str = as_graphql_literal(value) + + "#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" + end.join(", ") + end + + def as_graphql_literal(value) + self.class.as_graphql_literal(value) + end + + # Transform values to GraphQL literal arguments. + # Use symbol for Enum values + def self.as_graphql_literal(value) + case value + when ::Graphql::Arguments then "{#{value}}" + when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]" + when Hash then "{#{new(value)}}" + when Integer, Float, Symbol then value.to_s + when String then "\"#{value.gsub(/"/, '\\"')}\"" + when nil then 'null' + when true then 'true' + when false then 'false' + else + value.to_graphql_value + end + rescue NoMethodError + raise ArgumentError, "Cannot represent #{value} as GraphQL literal" + end + + def merge(other) + self.class.new(@values.merge(other.to_h)) + end + + def +(other) + if blank? + other + elsif other.blank? + self + elsif other.is_a?(String) + [to_s, other].compact.join(', ') + else + merge(other) + end + end + end +end diff --git a/spec/support/graphql/field_inspection.rb b/spec/support/graphql/field_inspection.rb new file mode 100644 index 00000000000..f39ba751141 --- /dev/null +++ b/spec/support/graphql/field_inspection.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Graphql + class FieldInspection + def initialize(field) + @field = field + end + + def nested_fields? + !scalar? && !enum? + end + + def scalar? + type.kind.scalar? + end + + def enum? + type.kind.enum? + end + + def type + @type ||= begin + field_type = @field.type.respond_to?(:to_graphql) ? @field.type.to_graphql : @field.type + + # The type could be nested. For example `[GraphQL::STRING_TYPE]`: + # - List + # - String! + # - String + field_type = field_type.of_type while field_type.respond_to?(:of_type) + + field_type + end + end + end +end diff --git a/spec/support/graphql/field_selection.rb b/spec/support/graphql/field_selection.rb new file mode 100644 index 00000000000..e2a3334acac --- /dev/null +++ b/spec/support/graphql/field_selection.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Graphql + class FieldSelection + delegate :empty?, :blank?, :to_h, to: :selection + delegate :size, to: :paths + + attr_reader :selection + + def initialize(selection = {}) + @selection = selection.to_h + end + + def to_s + serialize_field_selection(selection) + end + + def paths + selection.flat_map do |field, subselection| + paths_in([field], subselection) + end + end + + private + + def paths_in(path, leaves) + return [path] if leaves.nil? + + leaves.to_a.flat_map do |k, v| + paths_in([k], v).map { |tail| path + tail } + end + end + + def serialize_field_selection(hash, level = 0) + indent = ' ' * level + + hash.map do |field, subselection| + if subselection.nil? + "#{indent}#{field}" + else + subfields = serialize_field_selection(subselection, level + 1) + "#{indent}#{field} {\n#{subfields}\n#{indent}}" + end + end.join("\n") + end + + NO_SKIP = ->(_name, _field) { false } + + def self.select_fields(type, skip = NO_SKIP, parent_types = Set.new, max_depth = 3) + return new if max_depth <= 0 + + new(type.fields.flat_map do |name, field| + next [] if skip[name, field] + + inspected = ::Graphql::FieldInspection.new(field) + singular_field_type = inspected.type + + # If field type is the same as parent type, then we're hitting into + # mutual dependency. Break it from infinite recursion + next [] if parent_types.include?(singular_field_type) + + if inspected.nested_fields? + subselection = select_fields(singular_field_type, skip, parent_types | [type], max_depth - 1) + next [] if subselection.empty? + + [[name, subselection.to_h]] + else + [[name, nil]] + end + end) + end + end +end diff --git a/spec/support/graphql/var.rb b/spec/support/graphql/var.rb new file mode 100644 index 00000000000..4f2c774e898 --- /dev/null +++ b/spec/support/graphql/var.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Graphql + # Helper to pass variables around generated queries. + # + # e.g.: + # first = var('Int') + # after = var('String') + # + # query = with_signature( + # [first, after], + # query_graphql_path([ + # [:project, { full_path: project.full_path }], + # [:issues, { after: after, first: first }] + # :nodes + # ], all_graphql_fields_for('Issue')) + # ) + # + # post_graphql(query, variables: [first.with(2), after.with(some_cursor)]) + # + class Var + attr_reader :name, :type + attr_accessor :value + + def initialize(name, type) + @name = name + @type = type + end + + def sig + "#{to_graphql_value}: #{type}" + end + + def to_graphql_value + "$#{name}" + end + + # We return a new object so that running the same query twice with + # different values does not risk re-using the value + # + # e.g. + # + # x = var('Int') + # expect { post_graphql(query, variables: x) } + # .to issue_same_number_of_queries_as { post_graphql(query, variables: x.with(1)) } + # + # Here we post the `x` variable once with the value set to 1, and once with + # the value set to `nil`. + def with(value) + copy = Var.new(name, type) + copy.value = value + copy + end + + def to_h + { name => value } + end + end +end diff --git a/spec/support/helpers/after_next_helpers.rb b/spec/support/helpers/after_next_helpers.rb new file mode 100644 index 00000000000..0a7844fdd8f --- /dev/null +++ b/spec/support/helpers/after_next_helpers.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative './next_instance_of' + +module AfterNextHelpers + class DeferredExpectation + include ::NextInstanceOf + include ::RSpec::Matchers + include ::RSpec::Mocks::ExampleMethods + + def initialize(klass, args, level:) + @klass = klass + @args = args + @level = level.to_sym + end + + def to(condition) + run_condition(condition, asserted: true) + end + + def not_to(condition) + run_condition(condition, asserted: false) + end + + private + + attr_reader :klass, :args, :level + + def run_condition(condition, asserted:) + msg = asserted ? :to : :not_to + case level + when :expect + if asserted + expect_next_instance_of(klass, *args) { |instance| expect(instance).send(msg, condition) } + else + allow_next_instance_of(klass, *args) { |instance| expect(instance).send(msg, condition) } + end + when :allow + allow_next_instance_of(klass, *args) { |instance| allow(instance).send(msg, condition) } + else + raise "Unknown level: #{level}" + end + end + end + + def allow_next(klass, *args) + DeferredExpectation.new(klass, args, level: :allow) + end + + def expect_next(klass, *args) + DeferredExpectation.new(klass, args, level: :expect) + end +end diff --git a/spec/support/helpers/database_helpers.rb b/spec/support/helpers/database_helpers.rb new file mode 100644 index 00000000000..e9f0a74a8d1 --- /dev/null +++ b/spec/support/helpers/database_helpers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DatabaseHelpers + # In order to directly work with views using factories, + # we can swapout the view for a table of identical structure. + def swapout_view_for_table(view) + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE #{view}_copy (LIKE #{view}); + DROP VIEW #{view}; + ALTER TABLE #{view}_copy RENAME TO #{view}; + SQL + end +end diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb index 545b9d1f4d0..ebb849628bf 100644 --- a/spec/support/helpers/dependency_proxy_helpers.rb +++ b/spec/support/helpers/dependency_proxy_helpers.rb @@ -11,11 +11,18 @@ module DependencyProxyHelpers .to_return(status: status, body: body || auth_body) end - def stub_manifest_download(image, tag, status = 200, body = nil) + def stub_manifest_download(image, tag, status: 200, body: nil, headers: {}) manifest_url = registry.manifest_url(image, tag) stub_full_request(manifest_url) - .to_return(status: status, body: body || manifest) + .to_return(status: status, body: body || manifest, headers: headers) + end + + def stub_manifest_head(image, tag, status: 200, body: nil, digest: '123456') + manifest_url = registry.manifest_url(image, tag) + + stub_full_request(manifest_url, method: :head) + .to_return(status: status, body: body, headers: { 'docker-content-digest' => digest } ) end def stub_blob_download(image, blob_sha, status = 200, body = '123456') @@ -25,6 +32,13 @@ module DependencyProxyHelpers .to_return(status: status, body: body) end + def build_jwt(user = nil, expire_time: nil) + JSONWebToken::HMACToken.new(::Auth::DependencyProxyAuthenticationService.secret).tap do |jwt| + jwt['user_id'] = user.id if user + jwt.expire_time = expire_time || jwt.issued_at + 1.minute + end + end + private def registry diff --git a/spec/support/helpers/features/merge_request_helpers.rb b/spec/support/helpers/features/merge_request_helpers.rb new file mode 100644 index 00000000000..53896e1fe12 --- /dev/null +++ b/spec/support/helpers/features/merge_request_helpers.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Spec + module Support + module Helpers + module Features + module MergeRequestHelpers + def preload_view_requirements(merge_request, note) + # This will load the status fields of the author of the note and merge request + # to avoid queries when rendering the view being tested. + # + merge_request.author.status + note.author.status + end + + def serialize_issuable_sidebar(user, project, merge_request) + MergeRequestSerializer + .new(current_user: user, project: project) + .represent(merge_request, serializer: 'sidebar') + end + end + end + end + end +end diff --git a/spec/support/helpers/features/web_ide_spec_helpers.rb b/spec/support/helpers/features/web_ide_spec_helpers.rb index 12d3cecd052..358bfacce05 100644 --- a/spec/support/helpers/features/web_ide_spec_helpers.rb +++ b/spec/support/helpers/features/web_ide_spec_helpers.rb @@ -22,8 +22,6 @@ module WebIdeSpecHelpers click_link('Web IDE') wait_for_requests - - save_monaco_editor_reference end def ide_tree_body @@ -65,17 +63,6 @@ module WebIdeSpecHelpers ide_set_editor_value(content) end - def ide_rename_file(path, new_path) - container = ide_traverse_to_file(path) - - click_file_action(container, 'Rename/Move') - - within '#ide-new-entry' do - find('input').fill_in(with: new_path) - click_button('Rename file') - end - end - # Deletes a file by traversing to `path` # then clicking the 'Delete' action. # @@ -103,20 +90,6 @@ module WebIdeSpecHelpers container end - def ide_close_file(name) - within page.find('.multi-file-tabs') do - click_button("Close #{name}") - end - end - - def ide_open_file(path) - row = ide_traverse_to_file(path) - - ide_open_file_row(row) - - wait_for_requests - end - def ide_open_file_row(row) return if ide_folder_row_open?(row) @@ -130,10 +103,6 @@ module WebIdeSpecHelpers execute_script("monaco.editor.getModel('#{uri}').setValue('#{escape_javascript(value)}')") end - def ide_set_editor_position(line, col) - execute_script("TEST_EDITOR.setPosition(#{{ lineNumber: line, column: col }.to_json})") - end - def ide_editor_value editor = find('.monaco-editor') uri = editor['data-uri'] @@ -180,8 +149,4 @@ module WebIdeSpecHelpers wait_for_requests end end - - def save_monaco_editor_reference - evaluate_script("monaco.editor.onDidCreateEditor(editor => { window.TEST_EDITOR = editor; })") - end end diff --git a/spec/support/helpers/file_read_helpers.rb b/spec/support/helpers/file_read_helpers.rb new file mode 100644 index 00000000000..c30a9e6466f --- /dev/null +++ b/spec/support/helpers/file_read_helpers.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module FileReadHelpers + def stub_file_read(file, content: nil, error: nil) + allow_original_file_read + + expectation = allow(File).to receive(:read).with(file) + + if error + expectation.and_raise(error) + elsif content + expectation.and_return(content) + else + expectation + end + end + + def expect_file_read(file, content: nil, error: nil) + allow_original_file_read + + expectation = expect(File).to receive(:read).with(file) + + if error + expectation.and_raise(error) + elsif content + expectation.and_return(content) + else + expectation + end + end + + def expect_file_not_to_read(file) + allow_original_file_read + + expect(File).not_to receive(:read).with(file) + end + + private + + def allow_original_file_read + # Don't set this mock twice, otherwise subsequent calls will clobber + # previous mocks + return if @allow_original_file_read + + @allow_original_file_read = true + allow(File).to receive(:read).and_call_original + end +end diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index d203ff60cc9..10068b9c508 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -113,6 +113,10 @@ module FilteredSearchHelpers create_token('Assignee', assignee_name) end + def reviewer_token(reviewer_name = nil) + create_token('Reviewer', reviewer_name) + end + def milestone_token(milestone_name = nil, has_symbol = true, operator = '=') symbol = has_symbol ? '%' : nil create_token('Milestone', milestone_name, symbol, operator) diff --git a/spec/support/helpers/git_http_helpers.rb b/spec/support/helpers/git_http_helpers.rb index c9c1c4dcfc9..c9b4e06f067 100644 --- a/spec/support/helpers/git_http_helpers.rb +++ b/spec/support/helpers/git_http_helpers.rb @@ -5,45 +5,45 @@ require_relative 'workhorse_helpers' module GitHttpHelpers include WorkhorseHelpers - def clone_get(project, **options) - get "/#{project}/info/refs", params: { service: 'git-upload-pack' }, headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) + def clone_get(repository_path, **options) + get "/#{repository_path}/info/refs", params: { service: 'git-upload-pack' }, headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def clone_post(project, **options) - post "/#{project}/git-upload-pack", headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) + def clone_post(repository_path, **options) + post "/#{repository_path}/git-upload-pack", headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def push_get(project, **options) - get "/#{project}/info/refs", params: { service: 'git-receive-pack' }, headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) + def push_get(repository_path, **options) + get "/#{repository_path}/info/refs", params: { service: 'git-receive-pack' }, headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def push_post(project, **options) - post "/#{project}/git-receive-pack", headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) + def push_post(repository_path, **options) + post "/#{repository_path}/git-receive-pack", headers: auth_env(*options.values_at(:user, :password, :spnego_request_token)) end - def download(project, user: nil, password: nil, spnego_request_token: nil) + def download(repository_path, user: nil, password: nil, spnego_request_token: nil) args = { user: user, password: password, spnego_request_token: spnego_request_token } - clone_get(project, **args) + clone_get(repository_path, **args) yield response - clone_post(project, **args) + clone_post(repository_path, **args) yield response end - def upload(project, user: nil, password: nil, spnego_request_token: nil) + def upload(repository_path, user: nil, password: nil, spnego_request_token: nil) args = { user: user, password: password, spnego_request_token: spnego_request_token } - push_get(project, **args) + push_get(repository_path, **args) yield response - push_post(project, **args) + push_post(repository_path, **args) yield response end - def download_or_upload(project, **args, &block) - download(project, **args, &block) - upload(project, **args, &block) + def download_or_upload(repository_path, **args, &block) + download(repository_path, **args, &block) + upload(repository_path, **args, &block) end def auth_env(user, password, spnego_request_token) diff --git a/spec/support/helpers/gitlab_verify_helpers.rb b/spec/support/helpers/gitlab_verify_helpers.rb index 9901ce374ed..2a6ba8aaff4 100644 --- a/spec/support/helpers/gitlab_verify_helpers.rb +++ b/spec/support/helpers/gitlab_verify_helpers.rb @@ -2,7 +2,7 @@ module GitlabVerifyHelpers def collect_ranges(args = {}) - verifier = described_class.new(args.merge(batch_size: 1)) + verifier = described_class.new(**args.merge(batch_size: 1)) collect_results(verifier).map { |range, _| range } end diff --git a/spec/support/helpers/gpg_helpers.rb b/spec/support/helpers/gpg_helpers.rb index f4df1cf601c..389e5818dbe 100644 --- a/spec/support/helpers/gpg_helpers.rb +++ b/spec/support/helpers/gpg_helpers.rb @@ -144,6 +144,145 @@ module GpgHelpers '5F7EA3981A5845B141ABD522CCFBE19F00AC8B1D' end + def secret_key2 + <<~KEY.strip + -----BEGIN PGP PRIVATE KEY BLOCK----- + + lQWGBF+7O0oBDADvRto4K9PT83Lbyp/qaMPIzBbXHB6ljdDoyb+Pn2UrHk9MhB5v + bTgBv+rctOabmimPPalcyaxOQ1GtrYizo1l33YQZupSvaOoStVLWqnBx8eKKcUv8 + QucS3S2qFhj9G0tdHW7RW2BGrSwEM09d2xFsFKKAj/4RTTU5idYWrvB24DNcrBh+ + iKsoa+rmJf1bwL6Mn9f9NwzundG16qibY/UwMlltQriWaVMn2AKVuu6HrX9pe3g5 + Er2Szjc7DZitt6eAy3PmuWHXzDCCvsO7iPxXlywY49hLhDen3/Warwn1pSbp+im4 + /0oJExLZBSS1xHbRSQoR6matF0+V/6TQz8Yo3g8z9HgyEtn1V7QJo3PoNrnEl73e + 9yslTqVtzba0Q132oRoO7eEYf82KrPOmVGj6Q9LpSXFLfsl3GlPgoBxRZXpT62CV + 3rGalIa2yKmcBQtyICjR1+PTIAJcVIPyr92xTo4RfLwVFW0czX7LM2H0FT2Ksj7L + U450ewBz8N6bFDMAEQEAAf4HAwIkqHaeA9ofAv9oQj+upbqfdEmXd0krBv5R1Q3u + VZwtCdnf0KGtueJ7SpPHVbNB0gCYnYdgf59MF9HHuVjHTWCOBwBJ3hmc7Yt2NcZy + ow15C+2xy+6/ChIYz3K7cr3jFR17M8Rz430YpCeGdYq5CfNQvNlzHDjO7PClLOek + jqy7V0ME0j6Q5+gHKqz6ragrUkfQBK863T4/4IUE+oCcDkuPaQUJQcYbI81R60Tl + 4Rasi6njwj9MZlt9k8wfXmMInWAl7aLaEzTpwVFG8xZ5IHExWGHO9mS+DNqBRVd9 + oDQoYoLFW6w0wPIkcn1uoUJaDZoRFzy2AzFInS8oLPAYWg/Wg8TLyyTIHYq9Zn+B + 1mXeBHqx+TOCFq8P1wk9/A4MIl8cJmsEYrd2u0xdbVUQxCDzqrjqVmU4oamY6N6s + JPSp/hhBJB97CbCIoACB3aaH1CFDyXvyiqjobD5daKz8FlDzm4yze5n5b7CLwAWB + IA7nbNsGnLZiKQs+jmA6VcAax3nlulhG0YnzNLlwX4PgWjwjtd79rEmSdN9LsZE3 + R26377QFE6G5NLDiKg/96NsRYA1BsDnAWKpm64ZVHHbBxz/HiAP1Zncw3Ij5p8F1 + mtHK++qNF1P2OkAP01KaE2v6T+d3lCQzlPwnQIojW/NGvBZXarjV3916fN7rJamf + gs6Q72XKuXCOVJxGvknVGjXS97AIWbllLcCG5nYZx5BYaehMWOjrB9abD3h3lRXt + lT43gOFI53XY/vTw+jsPeT125QjjB3Kih5Ch5b6tXMj7X1Lkd9yTOIU0LVF5e9St + 1mvVl+pPwWafq60vlCtEnluwcEmH6XDiIABHDchgBdk+qsvc215bspyPRy4CRVAg + V3eaFFKgFrF/qDtzLgYVopcij1ovGmmox+m3mua4wSAs5Bm2UotEZfGscN6sCSfR + KAk83bV00rfjC/Zrgx3zn6PUqit5KcpLkQIo/CzUr9UCRC3tMIzFARbmjTE7f471 + +kUuJGxMONiRQC3ejLDZ/+B7WvZm44KffyKVlOSfG0MDUZzsINNY3jUskF2pfuq2 + acXqcVi16grRjyIsoRtZFM5/yu7ED7j4yZRRnBjD+E03uui5Rv3uiHcddE8nwwU+ + Tctvua+0QtS5NzFL6pM8tYdgRTXYekaoZf6N8sE3kgOlanvyXwxguNA7Y5Ns1mFC + JqIwOVwQbi8bk9I2PY9ER/nK6HRx2LpM466wRp7Bn9WAY8k/5gjzZrqVDCZJjuTO + mmhvGcm9wvsXxfb1NQdhc7ZHvCTj+Gf5hmdpzJnX0Cm83BqEEpmKk0HAXNCmMxQp + 3twrjrj/RahXVpnUgQR8PKAn7HjVFs/YvbQtTmFubmllIEJlcm5oYXJkIDxuYW5u + aWUuYmVybmhhcmRAZXhhbXBsZS5jb20+iQHUBBMBCgA+FiEExEem9r/Zzvj7NxeF + VxYlqTAkEXkFAl+7O0oCGwMFCQPCZwAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AA + CgkQVxYlqTAkEXk9xwv/WlJJGJ+QyGeJAhySG3z3bQnFwb2CusF2LbwcAETDgbkf + opkkf34Vbb9A7kM7peZ7Va0Edsg09XdkBUAdaqKQn78HiZJC5n0grXcj1c67Adss + Ym9TGVM6AC3K3Vm3wVV0X+ng31rdDpjfIqfYDAvwhMc8H/MHs/dCRSIxEGWK8UKh + WLUrX+wN+HNMVbzWPGwoTMWiDa/ofA9INhqN+u+mJkTaP+a4R3LTgL5hp+kUDOaB + Nc0rqH7vgj+037NTL8vox18J4qgNbRIsywclMYBJDwfA4w1phtsMu1BKPiOu2kue + 18fyGDtboXUPFOJjf5OEwJsu+MFogWeAVuHN/eeiqOAFCYW+TT6Ehc6BnJ8vWCMS + Dgs3t6i94gNZtvEty2EAheHEBD1alU4c6S3VENdh5q2KkWIVFxgNtungo03eAVfj + UhMjrrEu0LC/Rizo7Me0kG7rfdn9oIwp4MTn7Cst1wGEWdi9UO4NJf1C+P9rFQuG + hMaj+8gb1uBdjPG8WOOanQWGBF+7O0oBDADhzNAvjiphKHsa4O5s3BePLQ+DJz+K + rS8f9mb66to/w9BlUtnm/L4gVgiIYqGhH7TSDaGhvIDMf3iKKBnKrWeBe0W8cdq3 + FlzWC/AHUahEFxFm0l6nq0pOIiAVQ58IPaB/0a5YCY7tU2yfw8llZUN8dWJ7cSsB + Gpa6Q9/9y4x5/9VPDPduXRv22KCfDbHXuFS79ubmueFfrOa1CLXRhCy3dUXCyePU + YuwxixXJRTJQJm+A6c8TFIL+cji7IEzzDAiNexfGzEfu+Qj1/9PzX8aIn6C5Tf4q + B1pcGa4uYr8K1aCENcVt6+GA5gMdcplYXmtA212RyPqQmnJIjxDdS7AJYcivqG2q + F5CvqzKY5/A+e9+GLyRM36P8LpB8+XHMoYNMNmOl5KX6WZ1tRw/xxgv1iKX3Pcqd + noFwsOCNVpTWlxvjsyve8VQUplORSakIhfKh1VWu7j8AKXWe9S3zMYQDq5G8VrTO + Vb1pPvPgiNxo9u1OXi2H9UTXhCWYZ6FIe2UAEQEAAf4HAwIlxJFDCl1eRf+8ne6l + KpsQfPjhCNnaXE1Q1izRVNGn0gojZkHTRzBF6ZOaPMNSWOri22JoaACI2txuQLyu + fHdO+ROr2Pnp17zeXbrm9Tk0PpugPwW/+AkvLPtcSOoCLEzkoKnwKmpC224Ed2Zb + Ma5ApPp3HNGkZgPVw5Mvj8R/n8MbKr7/TC7PV9WInranisZqH9fzvA3KEpaDwSr0 + vBtn6nXzSQKhmwCGRLCUuA+HG2gXIlYuNi7lPpu+Tivz+FnIaTVtrhG5b6Az30QP + C0cLe539X9HgryP6M9kzLSYnfpGQMqSqOUYZfhQW6xtSWr7/iWdnYF7S1YouWPLs + vuN+xFFKv3eVtErk4UOgAp9it4/i41QuMNwCWCt71278Ugwqygexw/XMi+Rs2Z6C + 2ESu1dJnOhYF4eL7ymSKxwBitA+qETQBsjxjegNls/poFjREIhOOwM0w9mn+GptC + RVmFdcTlXMGJIGPxTFZQzIitCVoTURrkzBvqUvKFft8GcEBr2izoIqOZU3Npya7c + kKHyVMY0n7xjH3Hs4C3A4tBtkbDpwxz+hc9xh5/E/EKKlvZLfIKuuTP4eJap8KEN + vvbDPolF3TveTvNLIe86GTSU+wi67PM1PBHKhLSP2aYvS503Z29OLD6Rd6p6jI8u + MC8ueF719oH5uG5Sbs3OGmX+UF1aaproLhnGpTwrLyEX7tMebb/JM22Qasj9H9to + PNAgEfhlNdhJ+IULkx0My2e55+BIskhsWJpkAhpD2dOyiDBsXZvT3x3dbMKWi1sS + +nbKzhMjmUoQ++Vh2uZ9Zi93H3+gsge6e1duRSLNEFrrOk9c6cVPsmle7HoZSzNw + qYVCb3npMo+43IgyaK48eGS757ZGsgTEQdicoqVann+wHbAOlWwUFSPTGpqTMMvD + 17PVFQB4ADb5J3IAy7kJsVUwoqYI8VrdfiJJUeQikePOi760TCUTJ3PlMUNqngMn + ItzNidE8A0RvzFW6DNcPHJVpdGRk36GtWooBhxRwelchAgTSB6gVueF9KTW+EZU2 + evdAwuTfwvTguOuJ3yJ6g+vFiHYrsczHJXq7QaJbpmJLlavvA2yFPDmlSDMSMKFo + t13RwYZ+mPLS5QLK52vbCmDKiQI7Z7zLXIcQ2RXXHQN4OYYLbDXeIMO2BwXAsGJf + LC3W64gMUSRKB07UXmDdu4U3US0sqMsxUNWqLFC8PRVR68NAxF+8zS1xKLCUPRWS + ELivIY0m4ybzITM6xHBCOSFRph5+LKQVehEo1qM7aoRtS+5SHjdtOeyPEQwSTsWj + IWlumHJAXFUmBqc+bVi1m661c5O56VCm7PP61oQQxsB3J0E5OsQUA4kBvAQYAQoA + JhYhBMRHpva/2c74+zcXhVcWJakwJBF5BQJfuztKAhsMBQkDwmcAAAoJEFcWJakw + JBF5T/ML/3Ml7+493hQuoC9O3HOANkimc0pGxILVeJmJmnfbMDJ71fU84h2+xAyk + 2PZc48wVYKju9THJzdRk+XBPO+G6mSBupSt53JIYb5NijotNTmJmHYpG1yb+9FjD + EFWTlxK1mr5wjSUxlGWa/O46XjxzCSEUP1SknLWbTOucV8KOmPWL3DupvGINIIQx + e5eJ9SMjlHvUn4rq8sd11FT2bQrd+xMx8gP5cearPqB7qVRlHjtOKn29gTV90kIw + amRke8KxSoJh+xT057aKI2+MCu7RC8TgThmUVCWgwUzXlsw1Qe8ySc6CmjIBftfo + lQYPDSq1u8RSBAB+t2Xwprvdedr9SQihzBk5GCGBJ/npEcgF2jk26sJqoXYbvyQG + tqSDQ925oP7OstyOE4FTH7sQmBvP01Ikdgwkm0cthLSpWY4QI+09Aeg+rZ80Etfv + vAKquDGA33no8YGnn+epeLqyscIh4WG3bIoHk9JlFCcwIp9U65IfR1fTcvlTdzZN + 4f6xMfFu2A== + =3YL6 + -----END PGP PRIVATE KEY BLOCK----- + KEY + end + + def public_key2 + <<~KEY.strip + -----BEGIN PGP PUBLIC KEY BLOCK----- + + mQGNBF+7O0oBDADvRto4K9PT83Lbyp/qaMPIzBbXHB6ljdDoyb+Pn2UrHk9MhB5v + bTgBv+rctOabmimPPalcyaxOQ1GtrYizo1l33YQZupSvaOoStVLWqnBx8eKKcUv8 + QucS3S2qFhj9G0tdHW7RW2BGrSwEM09d2xFsFKKAj/4RTTU5idYWrvB24DNcrBh+ + iKsoa+rmJf1bwL6Mn9f9NwzundG16qibY/UwMlltQriWaVMn2AKVuu6HrX9pe3g5 + Er2Szjc7DZitt6eAy3PmuWHXzDCCvsO7iPxXlywY49hLhDen3/Warwn1pSbp+im4 + /0oJExLZBSS1xHbRSQoR6matF0+V/6TQz8Yo3g8z9HgyEtn1V7QJo3PoNrnEl73e + 9yslTqVtzba0Q132oRoO7eEYf82KrPOmVGj6Q9LpSXFLfsl3GlPgoBxRZXpT62CV + 3rGalIa2yKmcBQtyICjR1+PTIAJcVIPyr92xTo4RfLwVFW0czX7LM2H0FT2Ksj7L + U450ewBz8N6bFDMAEQEAAbQtTmFubmllIEJlcm5oYXJkIDxuYW5uaWUuYmVybmhh + cmRAZXhhbXBsZS5jb20+iQHUBBMBCgA+FiEExEem9r/Zzvj7NxeFVxYlqTAkEXkF + Al+7O0oCGwMFCQPCZwAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQVxYlqTAk + EXk9xwv/WlJJGJ+QyGeJAhySG3z3bQnFwb2CusF2LbwcAETDgbkfopkkf34Vbb9A + 7kM7peZ7Va0Edsg09XdkBUAdaqKQn78HiZJC5n0grXcj1c67AdssYm9TGVM6AC3K + 3Vm3wVV0X+ng31rdDpjfIqfYDAvwhMc8H/MHs/dCRSIxEGWK8UKhWLUrX+wN+HNM + VbzWPGwoTMWiDa/ofA9INhqN+u+mJkTaP+a4R3LTgL5hp+kUDOaBNc0rqH7vgj+0 + 37NTL8vox18J4qgNbRIsywclMYBJDwfA4w1phtsMu1BKPiOu2kue18fyGDtboXUP + FOJjf5OEwJsu+MFogWeAVuHN/eeiqOAFCYW+TT6Ehc6BnJ8vWCMSDgs3t6i94gNZ + tvEty2EAheHEBD1alU4c6S3VENdh5q2KkWIVFxgNtungo03eAVfjUhMjrrEu0LC/ + Rizo7Me0kG7rfdn9oIwp4MTn7Cst1wGEWdi9UO4NJf1C+P9rFQuGhMaj+8gb1uBd + jPG8WOOauQGNBF+7O0oBDADhzNAvjiphKHsa4O5s3BePLQ+DJz+KrS8f9mb66to/ + w9BlUtnm/L4gVgiIYqGhH7TSDaGhvIDMf3iKKBnKrWeBe0W8cdq3FlzWC/AHUahE + FxFm0l6nq0pOIiAVQ58IPaB/0a5YCY7tU2yfw8llZUN8dWJ7cSsBGpa6Q9/9y4x5 + /9VPDPduXRv22KCfDbHXuFS79ubmueFfrOa1CLXRhCy3dUXCyePUYuwxixXJRTJQ + Jm+A6c8TFIL+cji7IEzzDAiNexfGzEfu+Qj1/9PzX8aIn6C5Tf4qB1pcGa4uYr8K + 1aCENcVt6+GA5gMdcplYXmtA212RyPqQmnJIjxDdS7AJYcivqG2qF5CvqzKY5/A+ + e9+GLyRM36P8LpB8+XHMoYNMNmOl5KX6WZ1tRw/xxgv1iKX3PcqdnoFwsOCNVpTW + lxvjsyve8VQUplORSakIhfKh1VWu7j8AKXWe9S3zMYQDq5G8VrTOVb1pPvPgiNxo + 9u1OXi2H9UTXhCWYZ6FIe2UAEQEAAYkBvAQYAQoAJhYhBMRHpva/2c74+zcXhVcW + JakwJBF5BQJfuztKAhsMBQkDwmcAAAoJEFcWJakwJBF5T/ML/3Ml7+493hQuoC9O + 3HOANkimc0pGxILVeJmJmnfbMDJ71fU84h2+xAyk2PZc48wVYKju9THJzdRk+XBP + O+G6mSBupSt53JIYb5NijotNTmJmHYpG1yb+9FjDEFWTlxK1mr5wjSUxlGWa/O46 + XjxzCSEUP1SknLWbTOucV8KOmPWL3DupvGINIIQxe5eJ9SMjlHvUn4rq8sd11FT2 + bQrd+xMx8gP5cearPqB7qVRlHjtOKn29gTV90kIwamRke8KxSoJh+xT057aKI2+M + Cu7RC8TgThmUVCWgwUzXlsw1Qe8ySc6CmjIBftfolQYPDSq1u8RSBAB+t2Xwprvd + edr9SQihzBk5GCGBJ/npEcgF2jk26sJqoXYbvyQGtqSDQ925oP7OstyOE4FTH7sQ + mBvP01Ikdgwkm0cthLSpWY4QI+09Aeg+rZ80EtfvvAKquDGA33no8YGnn+epeLqy + scIh4WG3bIoHk9JlFCcwIp9U65IfR1fTcvlTdzZN4f6xMfFu2A== + =RAwd + -----END PGP PUBLIC KEY BLOCK----- + KEY + end + + def fingerprint2 + 'C447A6F6BFD9CEF8FB371785571625A930241179' + end + def names ['Nannie Bernhard'] end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index a1b4e6eee92..b20801bd3c4 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -4,6 +4,11 @@ module GraphqlHelpers MutationDefinition = Struct.new(:query, :variables) NoData = Class.new(StandardError) + UnauthorizedObject = Class.new(StandardError) + + def graphql_args(**values) + ::Graphql::Arguments.new(values) + end # makes an underscored string look like a fieldname # "merge_request" => "mergeRequest" @@ -17,7 +22,10 @@ module GraphqlHelpers # ready, then the early return is returned instead. # # Then the resolve method is called. - def resolve(resolver_class, args: {}, **resolver_args) + def resolve(resolver_class, args: {}, lookahead: :not_given, parent: :not_given, **resolver_args) + args = aliased_args(resolver_class, args) + args[:parent] = parent unless parent == :not_given + args[:lookahead] = lookahead unless lookahead == :not_given resolver = resolver_instance(resolver_class, **resolver_args) ready, early_return = sync_all { resolver.ready?(**args) } @@ -26,6 +34,16 @@ module GraphqlHelpers resolver.resolve(**args) end + # TODO: Remove this method entirely when GraphqlHelpers uses real resolve_field + # see: https://gitlab.com/gitlab-org/gitlab/-/issues/287791 + def aliased_args(resolver, args) + definitions = resolver.arguments + + args.transform_keys do |k| + definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k + end + end + def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema) if ctx.is_a?(Hash) q = double('Query', schema: schema) @@ -111,24 +129,33 @@ module GraphqlHelpers def variables_for_mutation(name, input) graphql_input = prepare_input_for_mutation(input) - result = { input_variable_name_for_mutation(name) => graphql_input } + { input_variable_name_for_mutation(name) => graphql_input } + end - # Avoid trying to serialize multipart data into JSON - if graphql_input.values.none? { |value| io_value?(value) } - result.to_json - else - result - end + def serialize_variables(variables) + return unless variables + return variables if variables.is_a?(String) + + ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json end - def resolve_field(name, object, args = {}) - context = double("Context", - schema: GitlabSchema, - query: GraphQL::Query.new(GitlabSchema), - parent: nil) - field = described_class.fields[name] + def resolve_field(name, object, args = {}, current_user: nil) + q = GraphQL::Query.new(GitlabSchema) + context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user }) + allow(context).to receive(:parent).and_return(nil) + field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name)) instance = described_class.authorized_new(object, context) - field.resolve_field(instance, {}, context) + raise UnauthorizedObject unless instance + + field.resolve_field(instance, args, context) + end + + def simple_resolver(resolved_value = 'Resolved value') + Class.new(Resolvers::BaseResolver) do + define_method :resolve do |**_args| + resolved_value + end + end end # Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys @@ -165,10 +192,32 @@ module GraphqlHelpers end def query_graphql_field(name, attributes = {}, fields = nil) - <<~QUERY - #{field_with_params(name, attributes)} - #{wrap_fields(fields || all_graphql_fields_for(name.to_s.classify))} - QUERY + attributes, fields = [nil, attributes] if fields.nil? && !attributes.is_a?(Hash) + + field = field_with_params(name, attributes) + + field + wrap_fields(fields || all_graphql_fields_for(name.to_s.classify)).to_s + end + + def page_info_selection + "pageInfo { hasNextPage hasPreviousPage endCursor startCursor }" + end + + def query_nodes(name, fields = nil, args: nil, of: name, include_pagination_info: false, max_depth: 1) + fields ||= all_graphql_fields_for(of.to_s.classify, max_depth: max_depth) + node_selection = include_pagination_info ? "#{page_info_selection} nodes" : :nodes + query_graphql_path([[name, args], node_selection], fields) + end + + # e.g: + # query_graphql_path(%i[foo bar baz], all_graphql_fields_for('Baz')) + # => foo { bar { baz { x y z } } } + def query_graphql_path(segments, fields = nil) + # we really want foldr here... + segments.reverse.reduce(fields) do |tail, segment| + name, args = Array.wrap(segment) + query_graphql_field(name, args, tail) + end end def wrap_fields(fields) @@ -203,50 +252,22 @@ module GraphqlHelpers type = GitlabSchema.types[class_name.to_s] return "" unless type - type.fields.map do |name, field| - # We can't guess arguments, so skip fields that require them - next if required_arguments?(field) - next if excluded.include?(name) - - singular_field_type = field_type(field) - - # If field type is the same as parent type, then we're hitting into - # mutual dependency. Break it from infinite recursion - next if parent_types.include?(singular_field_type) + # We can't guess arguments, so skip fields that require them + skip = ->(name, field) { excluded.include?(name) || required_arguments?(field) } - if nested_fields?(field) - fields = - all_graphql_fields_for(singular_field_type, parent_types | [type], max_depth: max_depth - 1) - - "#{name} { #{fields} }" unless fields.blank? - else - name - end - end.compact.join("\n") + ::Graphql::FieldSelection.select_fields(type, skip, parent_types, max_depth) end - def attributes_to_graphql(attributes) - attributes.map do |name, value| - value_str = as_graphql_literal(value) + def with_signature(variables, query) + %Q[query(#{variables.map(&:sig).join(', ')}) #{query}] + end - "#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" - end.join(", ") + def var(type) + ::Graphql::Var.new(generate(:variable), type) end - # Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing. - # Use symbol for Enum values - def as_graphql_literal(value) - case value - when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]" - when Hash then "{#{attributes_to_graphql(value)}}" - when Integer, Float then value.to_s - when String then "\"#{value.gsub(/"/, '\\"')}\"" - when Symbol then value - when nil then 'null' - when true then 'true' - when false then 'false' - else raise ArgumentError, "Cannot represent #{value} as GraphQL literal" - end + def attributes_to_graphql(arguments) + ::Graphql::Arguments.new(arguments).to_s end def post_multiplex(queries, current_user: nil, headers: {}) @@ -254,7 +275,7 @@ module GraphqlHelpers end def post_graphql(query, current_user: nil, variables: nil, headers: {}) - params = { query: query, variables: variables&.to_json } + params = { query: query, variables: serialize_variables(variables) } post api('/', current_user, version: 'graphql'), params: params, headers: headers end @@ -320,36 +341,47 @@ module GraphqlHelpers { operations: operations.to_json, map: map.to_json }.merge(extracted_files) end + def fresh_response_data + Gitlab::Json.parse(response.body) + end + # Raises an error if no data is found - def graphql_data + def graphql_data(body = json_response) # Note that `json_response` is defined as `let(:json_response)` and # therefore, in a spec with multiple queries, will only contain data # from the _first_ query, not subsequent ones - json_response['data'] || (raise NoData, graphql_errors) + body['data'] || (raise NoData, graphql_errors(body)) end def graphql_data_at(*path) graphql_dig_at(graphql_data, *path) end + # Slightly more powerful than just `dig`: + # - also supports implicit flat-mapping (.e.g. :foo :nodes :bar :nodes) def graphql_dig_at(data, *path) keys = path.map { |segment| segment.is_a?(Integer) ? segment : GraphqlHelpers.fieldnamerize(segment) } # Allows for array indexing, like this # ['project', 'boards', 'edges', 0, 'node', 'lists'] keys.reduce(data) do |memo, key| - memo.is_a?(Array) ? memo[key] : memo&.dig(key) + if memo.is_a?(Array) + key.is_a?(Integer) ? memo[key] : memo.flat_map { |e| Array.wrap(e[key]) } + else + memo&.dig(key) + end end end - def graphql_errors - case json_response + # See note at graphql_data about memoization and multiple requests + def graphql_errors(body = json_response) + case body when Hash # regular query - json_response['errors'] + body['errors'] when Array # multiplexed queries - json_response.map { |response| response['errors'] } + body.map { |response| response['errors'] } else - raise "Unknown GraphQL response type #{json_response.class}" + raise "Unknown GraphQL response type #{body.class}" end end @@ -392,19 +424,29 @@ module GraphqlHelpers end def nested_fields?(field) - !scalar?(field) && !enum?(field) + ::Graphql::FieldInspection.new(field).nested_fields? end def scalar?(field) - field_type(field).kind.scalar? + ::Graphql::FieldInspection.new(field).scalar? end def enum?(field) - field_type(field).kind.enum? + ::Graphql::FieldInspection.new(field).enum? end + # There are a few non BaseField fields in our schema (pageInfo for one). + # None of them require arguments. def required_arguments?(field) - field.arguments.values.any? { |argument| argument.type.non_null? } + return field.requires_argument? if field.is_a?(::Types::BaseField) + + if (meta = field.try(:metadata)) && meta[:type_class] + required_arguments?(meta[:type_class]) + elsif args = field.try(:arguments) + args.values.any? { |argument| argument.type.non_null? } + else + false + end end def io_value?(value) @@ -412,15 +454,7 @@ module GraphqlHelpers end def field_type(field) - field_type = field.type.respond_to?(:to_graphql) ? field.type.to_graphql : field.type - - # The type could be nested. For example `[GraphQL::STRING_TYPE]`: - # - List - # - String! - # - String - field_type = field_type.of_type while field_type.respond_to?(:of_type) - - field_type + ::Graphql::FieldInspection.new(field).type end # for most tests, we want to allow unlimited complexity @@ -498,6 +532,20 @@ module GraphqlHelpers variables: {} ) end + + # A lookahead that selects everything + def positive_lookahead + double(selects?: true).tap do |selection| + allow(selection).to receive(:selection).and_return(selection) + end + end + + # A lookahead that selects nothing + def negative_lookahead + double(selects?: false).tap do |selection| + allow(selection).to receive(:selection).and_return(selection) + end + end end # This warms our schema, doing this as part of loading the helpers to avoid diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb index e21d4497cda..86022e16d71 100644 --- a/spec/support/helpers/login_helpers.rb +++ b/spec/support/helpers/login_helpers.rb @@ -138,13 +138,15 @@ module LoginHelpers secret: 'mock_secret' }, extra: { - raw_info: { - info: { - name: 'mockuser', - email: email, - image: 'mock_user_thumbnail_url' + raw_info: OneLogin::RubySaml::Attributes.new( + { + info: { + name: 'mockuser', + email: email, + image: 'mock_user_thumbnail_url' + } } - }, + ), response_object: response_object } }).merge(additional_info) { |_, old_hash, new_hash| old_hash.merge(new_hash) } @@ -198,7 +200,7 @@ module LoginHelpers env['omniauth.error.strategy'] = strategy end - def stub_omniauth_saml_config(messages, context: Rails.application) + def stub_omniauth_saml_config(context: Rails.application, **messages) set_devise_mapping(context: context) routes = Rails.application.routes routes.disable_clear_and_finalize = true diff --git a/spec/support/helpers/multipart_helpers.rb b/spec/support/helpers/multipart_helpers.rb index 2e8db0e9e42..bcb184f84c5 100644 --- a/spec/support/helpers/multipart_helpers.rb +++ b/spec/support/helpers/multipart_helpers.rb @@ -37,7 +37,7 @@ module MultipartHelpers # *without* the "#{key}." prefix result.deep_transform_keys! { |k| k.remove("#{key}.") } { - "#{key}.gitlab-workhorse-upload" => jwt_token('upload' => result) + "#{key}.gitlab-workhorse-upload" => jwt_token(data: { 'upload' => result }) } end diff --git a/spec/support/helpers/next_instance_of.rb b/spec/support/helpers/next_instance_of.rb index 83c788c3d38..a8e9ab2bafe 100644 --- a/spec/support/helpers/next_instance_of.rb +++ b/spec/support/helpers/next_instance_of.rb @@ -1,28 +1,31 @@ # frozen_string_literal: true module NextInstanceOf - def expect_next_instance_of(klass, *new_args) - stub_new(expect(klass), *new_args) do |expectation| - yield(expectation) - end + def expect_next_instance_of(klass, *new_args, &blk) + stub_new(expect(klass), nil, *new_args, &blk) end - def allow_next_instance_of(klass, *new_args) - stub_new(allow(klass), *new_args) do |allowance| - yield(allowance) - end + def expect_next_instances_of(klass, number, *new_args, &blk) + stub_new(expect(klass), number, *new_args, &blk) + end + + def allow_next_instance_of(klass, *new_args, &blk) + stub_new(allow(klass), nil, *new_args, &blk) + end + + def allow_next_instances_of(klass, number, *new_args, &blk) + stub_new(allow(klass), number, *new_args, &blk) end private - def stub_new(target, *new_args) + def stub_new(target, number, *new_args, &blk) receive_new = receive(:new) + receive_new.exactly(number).times if number receive_new.with(*new_args) if new_args.any? target.to receive_new.and_wrap_original do |method, *original_args| - method.call(*original_args).tap do |instance| - yield(instance) - end + method.call(*original_args).tap(&blk) end end end diff --git a/spec/support/helpers/services_helper.rb b/spec/support/helpers/services_helper.rb new file mode 100644 index 00000000000..bf007815551 --- /dev/null +++ b/spec/support/helpers/services_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require_relative './after_next_helpers' + +module ServicesHelper + include AfterNextHelpers + + def expect_execution_of(service_class, *args) + expect_next(service_class, *args).to receive(:execute) + end +end diff --git a/spec/support/helpers/snowplow_helpers.rb b/spec/support/helpers/snowplow_helpers.rb index 15eac1b24fc..70a4eadd8de 100644 --- a/spec/support/helpers/snowplow_helpers.rb +++ b/spec/support/helpers/snowplow_helpers.rb @@ -31,7 +31,31 @@ module SnowplowHelpers # ) # end # end - def expect_snowplow_event(category:, action:, **kwargs) + # + # Passing context: + # + # Simply provide a hash that has the schema and data expected. + # + # expect_snowplow_event( + # category: 'Experiment', + # action: 'created', + # context: [ + # { + # schema: 'iglu:com.gitlab/.../0-3-0', + # data: { key: 'value' } + # } + # ] + # ) + def expect_snowplow_event(category:, action:, context: nil, **kwargs) + if context + kwargs[:context] = [] + context.each do |c| + expect(SnowplowTracker::SelfDescribingJson).to have_received(:new) + .with(c[:schema], c[:data]).at_least(:once) + kwargs[:context] << an_instance_of(SnowplowTracker::SelfDescribingJson) + end + end + expect(Gitlab::Tracking).to have_received(:event) # rubocop:disable RSpec/ExpectGitlabTracking .with(category, action, **kwargs).at_least(:once) end diff --git a/spec/support/helpers/stub_experiments.rb b/spec/support/helpers/stub_experiments.rb index 7a6154d5ef9..247692d83ee 100644 --- a/spec/support/helpers/stub_experiments.rb +++ b/spec/support/helpers/stub_experiments.rb @@ -3,15 +3,15 @@ module StubExperiments # Stub Experiment with `key: true/false` # - # @param [Hash] experiment where key is feature name and value is boolean whether enabled or not. + # @param [Hash] experiment where key is feature name and value is boolean whether active or not. # # Examples - # - `stub_experiment(signup_flow: false)` ... Disable `signup_flow` experiment globally. + # - `stub_experiment(signup_flow: false)` ... Disables `signup_flow` experiment. def stub_experiment(experiments) - allow(Gitlab::Experimentation).to receive(:enabled?).and_call_original + allow(Gitlab::Experimentation).to receive(:active?).and_call_original experiments.each do |experiment_key, enabled| - allow(Gitlab::Experimentation).to receive(:enabled?).with(experiment_key) { enabled } + allow(Gitlab::Experimentation).to receive(:active?).with(experiment_key) { enabled } end end @@ -20,12 +20,12 @@ module StubExperiments # @param [Hash] experiment where key is feature name and value is boolean whether enabled or not. # # Examples - # - `stub_experiment_for_user(signup_flow: false)` ... Disable `signup_flow` experiment for user. - def stub_experiment_for_user(experiments) - allow(Gitlab::Experimentation).to receive(:enabled_for_value?).and_call_original + # - `stub_experiment_for_subject(signup_flow: false)` ... Disable `signup_flow` experiment for user. + def stub_experiment_for_subject(experiments) + allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_call_original experiments.each do |experiment_key, enabled| - allow(Gitlab::Experimentation).to receive(:enabled_for_value?).with(experiment_key, anything) { enabled } + allow(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, anything) { enabled } end end end diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index dba3d2b137e..dc54a21d0fa 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -22,6 +22,16 @@ module StubObjectStorage background_upload: false, direct_upload: false ) + new_config = config.to_h.deep_symbolize_keys.merge({ + enabled: enabled, + proxy_download: proxy_download, + background_upload: background_upload, + direct_upload: direct_upload + }) + + # Needed for ObjectStorage::Config compatibility + allow(config).to receive(:to_hash).and_return(new_config) + allow(config).to receive(:to_h).and_return(new_config) allow(config).to receive(:enabled) { enabled } allow(config).to receive(:proxy_download) { proxy_download } allow(config).to receive(:background_upload) { background_upload } @@ -84,13 +94,6 @@ module StubObjectStorage def stub_terraform_state_object_storage(**params) stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store, - uploader: Terraform::VersionedStateUploader, - remote_directory: 'terraform', - **params) - end - - def stub_terraform_state_version_object_storage(**params) - stub_object_storage_uploader(config: Gitlab.config.terraform_state.object_store, uploader: Terraform::StateUploader, remote_directory: 'terraform', **params) diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 4c78ca0117c..01571277a1d 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -168,6 +168,11 @@ module TestEnv version: Gitlab::GitalyClient.expected_server_version, task: "gitlab:gitaly:install[#{install_gitaly_args}]") do Gitlab::SetupHelper::Gitaly.create_configuration(gitaly_dir, { 'default' => repos_path }, force: true) + Gitlab::SetupHelper::Gitaly.create_configuration( + gitaly_dir, + { 'default' => repos_path }, force: true, + options: { gitaly_socket: "gitaly2.socket", config_filename: "gitaly2.config.toml" } + ) Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true) end @@ -241,15 +246,38 @@ module TestEnv end def setup_workhorse - install_workhorse_args = [workhorse_dir, workhorse_url].compact.join(',') - - component_timed_setup( - 'GitLab Workhorse', - install_dir: workhorse_dir, - version: Gitlab::Workhorse.version, - task: "gitlab:workhorse:install[#{install_workhorse_args}]") do - Gitlab::SetupHelper::Workhorse.create_configuration(workhorse_dir, nil) - end + start = Time.now + return if skip_compile_workhorse? + + puts "\n==> Setting up GitLab Workhorse..." + + FileUtils.rm_rf(workhorse_dir) + Gitlab::SetupHelper::Workhorse.compile_into(workhorse_dir) + Gitlab::SetupHelper::Workhorse.create_configuration(workhorse_dir, nil) + + File.write(workhorse_tree_file, workhorse_tree) if workhorse_source_clean? + + puts " GitLab Workhorse set up in #{Time.now - start} seconds...\n" + end + + def skip_compile_workhorse? + File.directory?(workhorse_dir) && + workhorse_source_clean? && + File.exist?(workhorse_tree_file) && + workhorse_tree == File.read(workhorse_tree_file) + end + + def workhorse_source_clean? + out = IO.popen(%w[git status --porcelain workhorse], &:read) + $?.success? && out.empty? + end + + def workhorse_tree + IO.popen(%w[git rev-parse HEAD:workhorse], &:read) + end + + def workhorse_tree_file + File.join(workhorse_dir, 'WORKHORSE_TREE') end def workhorse_dir @@ -260,7 +288,7 @@ module TestEnv host = "[#{host}]" if host.include?(':') listen_addr = [host, port].join(':') - config_path = Gitlab::SetupHelper::Workhorse.get_config_path(workhorse_dir) + 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, diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index 8e8aeea2ea1..df79049123d 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -85,6 +85,7 @@ module UsageDataHelpers projects projects_imported_from_github projects_asana_active + projects_jenkins_active projects_jira_active projects_jira_server_active projects_jira_cloud_active @@ -161,7 +162,6 @@ module UsageDataHelpers git gitaly database - avg_cycle_analytics prometheus_metrics_enabled web_ide_clientside_preview_enabled ingress_modsecurity_enabled diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb index 7e95f49aea2..cd8387de686 100644 --- a/spec/support/helpers/workhorse_helpers.rb +++ b/spec/support/helpers/workhorse_helpers.rb @@ -85,15 +85,15 @@ module WorkhorseHelpers return {} if upload_params.empty? - { "#{key}.gitlab-workhorse-upload" => jwt_token('upload' => upload_params) } + { "#{key}.gitlab-workhorse-upload" => jwt_token(data: { 'upload' => upload_params }) } end - def jwt_token(data = {}, issuer: 'gitlab-workhorse', secret: Gitlab::Workhorse.secret, algorithm: 'HS256') + 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) - { Gitlab::Middleware::Multipart::RACK_ENV_KEY => jwt_token('rewritten_fields' => fields) } + { Gitlab::Middleware::Multipart::RACK_ENV_KEY => jwt_token(data: { 'rewritten_fields' => fields }) } end def workhorse_disk_accelerated_file_params(key, file) diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb index ae951ea35af..a6b395ad4d5 100644 --- a/spec/support/import_export/common_util.rb +++ b/spec/support/import_export/common_util.rb @@ -70,9 +70,12 @@ module ImportExport ) end - def get_shared_env(path:) + def get_shared_env(path:, logger: nil) + logger ||= double(info: true, warn: true, error: true) + instance_double(Gitlab::ImportExport::Shared).tap do |shared| allow(shared).to receive(:export_path).and_return(path) + allow(shared).to receive(:logger).and_return(logger) end end diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb index c9ff777f604..acf5fb0944f 100644 --- a/spec/support/matchers/access_matchers.rb +++ b/spec/support/matchers/access_matchers.rb @@ -52,7 +52,7 @@ module AccessMatchers emulate_user(user, @membership) visit(url) - status_code == 200 && current_path != new_user_session_path + status_code == 200 && !current_path.in?([new_user_session_path, new_admin_session_path]) end chain :of do |membership| @@ -67,7 +67,7 @@ module AccessMatchers emulate_user(user, @membership) visit(url) - [401, 404].include?(status_code) || current_path == new_user_session_path + [401, 404, 403].include?(status_code) || current_path.in?([new_user_session_path, new_admin_session_path]) end chain :of do |membership| diff --git a/spec/support/matchers/be_valid_json.rb b/spec/support/matchers/be_valid_json.rb new file mode 100644 index 00000000000..f46c35c7198 --- /dev/null +++ b/spec/support/matchers/be_valid_json.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :be_valid_json do + def according_to_schema(schema) + @schema = schema + self + end + + match do |actual| + data = Gitlab::Json.parse(actual) + + if @schema.present? + @validation_errors = JSON::Validator.fully_validate(@schema, data) + @validation_errors.empty? + else + data.present? + end + rescue JSON::ParserError => e + @error = e + false + end + + def failure_message + if @error + "Parse failed with error: #{@error}" + elsif @validation_errors.present? + "Validation failed because #{@validation_errors.join(', and ')}" + else + "Parsing did not return any data" + end + end +end diff --git a/spec/support/matchers/exceed_query_limit.rb b/spec/support/matchers/exceed_query_limit.rb index 04482d3bfb8..7a66eff3a41 100644 --- a/spec/support/matchers/exceed_query_limit.rb +++ b/spec/support/matchers/exceed_query_limit.rb @@ -3,6 +3,13 @@ module ExceedQueryLimitHelpers MARGINALIA_ANNOTATION_REGEX = %r{\s*\/\*.*\*\/}.freeze + DB_QUERY_RE = Regexp.union([ + /^(?<prefix>SELECT .* FROM "?[a-z_]+"?) (?<suffix>.*)$/m, + /^(?<prefix>UPDATE "?[a-z_]+"?) (?<suffix>.*)$/m, + /^(?<prefix>INSERT INTO "[a-z_]+" \((?:"[a-z_]+",?\s?)+\)) (?<suffix>.*)$/m, + /^(?<prefix>DELETE FROM "[a-z_]+") (?<suffix>.*)$/m + ]).freeze + def with_threshold(threshold) @threshold = threshold self @@ -13,41 +20,129 @@ module ExceedQueryLimitHelpers self end + def show_common_queries + @show_common_queries = true + self + end + + def ignoring(pattern) + @ignoring_pattern = pattern + self + end + def threshold @threshold.to_i end def expected_count if expected.is_a?(ActiveRecord::QueryRecorder) - expected.count + query_recorder_count(expected) else expected end end def actual_count - @actual_count ||= if @query - recorder.log.select { |recorded| recorded =~ @query }.size - else - recorder.count - end + @actual_count ||= query_recorder_count(recorder) + end + + def query_recorder_count(query_recorder) + return query_recorder.count unless @query || @ignoring_pattern + + query_log(query_recorder).size + end + + def query_log(query_recorder) + filtered = query_recorder.log + filtered = filtered.select { |q| q =~ @query } if @query + filtered = filtered.reject { |q| q =~ @ignoring_pattern } if @ignoring_pattern + filtered end def recorder @recorder ||= ActiveRecord::QueryRecorder.new(skip_cached: skip_cached, &@subject_block) end - def count_queries(queries) - queries.each_with_object(Hash.new(0)) { |query, counts| counts[query] += 1 } + # Take a query recorder and tabulate the frequencies of suffixes for each prefix. + # + # @return Hash[String, Hash[String, Int]] + # + # Example: + # + # r = ActiveRecord::QueryRecorder.new do + # SomeTable.create(x: 1, y: 2, z: 3) + # SomeOtherTable.where(id: 1).first + # SomeTable.create(x: 4, y: 5, z: 6) + # SomeOtherTable.all + # end + # count_queries(r) + # #=> + # { + # 'INSERT INTO "some_table" VALUES' => { + # '(1,2,3)' => 1, + # '(4,5,6)' => 1 + # }, + # 'SELECT * FROM "some_other_table"' => { + # 'WHERE id = 1 LIMIT 1' => 1, + # '' => 2 + # } + # } + def count_queries(query_recorder) + strip_marginalia_annotations(query_log(query_recorder)) + .map { |q| query_group_key(q) } + .group_by { |k| k[:prefix] } + .transform_values { |keys| frequencies(:suffix, keys) } + end + + def frequencies(key, things) + things.group_by { |x| x[key] }.transform_values(&:size) + end + + def query_group_key(query) + DB_QUERY_RE.match(query) || { prefix: query, suffix: '' } + end + + def diff_query_counts(expected, actual) + expected_counts = expected.transform_values do |suffixes| + suffixes.transform_values { |n| [n, 0] } + end + recorded_counts = actual.transform_values do |suffixes| + suffixes.transform_values { |n| [0, n] } + end + + combined_counts = expected_counts.merge(recorded_counts) do |_k, exp, got| + exp.merge(got) do |_k, exp_counts, got_counts| + exp_counts.zip(got_counts).map { |a, b| a + b } + end + end + + unless @show_common_queries + combined_counts = combined_counts.transform_values do |suffs| + suffs.reject { |_k, counts| counts.first == counts.second } + end + end + + combined_counts.reject { |_prefix, suffs| suffs.empty? } + end + + def diff_query_group_message(query, suffixes) + suffix_messages = suffixes.map do |s, counts| + "-- (expected: #{counts.first}, got: #{counts.second})\n #{s}" + end + + "#{query}...\n#{suffix_messages.join("\n")}" end def log_message if expected.is_a?(ActiveRecord::QueryRecorder) - counts = count_queries(strip_marginalia_annotations(expected.log)) - extra_queries = strip_marginalia_annotations(@recorder.log).reject { |query| counts[query] -= 1 unless counts[query] == 0 } - extra_queries_display = count_queries(extra_queries).map { |query, count| "[#{count}] #{query}" } - - (['Extra queries:'] + extra_queries_display).join("\n\n") + diff_counts = diff_query_counts(count_queries(expected), count_queries(@recorder)) + sections = diff_counts.map { |q, suffixes| diff_query_group_message(q, suffixes) } + + <<~MSG + Query Diff: + ----------- + #{sections.join("\n\n")} + MSG else @recorder.log_message end diff --git a/spec/support/services/issuable_import_csv_service_shared_examples.rb b/spec/support/services/issuable_import_csv_service_shared_examples.rb index 20ac2ff5c7c..f68750bec32 100644 --- a/spec/support/services/issuable_import_csv_service_shared_examples.rb +++ b/spec/support/services/issuable_import_csv_service_shared_examples.rb @@ -26,29 +26,33 @@ RSpec.shared_examples 'issuable import csv service' do |issuable_type| end end + shared_examples_for 'invalid file' do + it 'returns invalid file error' do + expect(subject[:success]).to eq(0) + expect(subject[:parse_error]).to eq(true) + end + + it_behaves_like 'importer with email notification' + it_behaves_like 'an issuable importer' + end + describe '#execute' do - context 'invalid file' do + context 'invalid file extension' do let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') } - it 'returns invalid file error' do - expect(subject[:success]).to eq(0) - expect(subject[:parse_error]).to eq(true) - end + it_behaves_like 'invalid file' + end - it_behaves_like 'importer with email notification' - it_behaves_like 'an issuable importer' + context 'empty file' do + let(:file) { fixture_file_upload('spec/fixtures/csv_empty.csv') } + + it_behaves_like 'invalid file' end context 'file without headers' do let(:file) { fixture_file_upload('spec/fixtures/csv_no_headers.csv') } - it 'returns invalid file error' do - expect(subject[:success]).to eq(0) - expect(subject[:parse_error]).to eq(true) - end - - it_behaves_like 'importer with email notification' - it_behaves_like 'an issuable importer' + it_behaves_like 'invalid file' end context 'with a file generated by Gitlab CSV export' do diff --git a/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb index 68ff16922d8..6b15eadc1c1 100644 --- a/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb @@ -9,13 +9,13 @@ RSpec.shared_context 'GroupProjectsFinder context' do let(:finder) { described_class.new(group: group, current_user: current_user, params: params, options: options) } - let_it_be(:public_project) { create(:project, :public, group: group, path: '1') } - let_it_be(:private_project) { create(:project, :private, group: group, path: '2') } - let_it_be(:shared_project_1) { create(:project, :public, path: '3') } - let_it_be(:shared_project_2) { create(:project, :private, path: '4') } - let_it_be(:shared_project_3) { create(:project, :internal, path: '5') } - let_it_be(:subgroup_project) { create(:project, :public, path: '6', group: subgroup) } - let_it_be(:subgroup_private_project) { create(:project, :private, path: '7', group: subgroup) } + let_it_be(:public_project) { create(:project, :public, group: group, path: '1', name: 'g') } + let_it_be(:private_project) { create(:project, :private, group: group, path: '2', name: 'f') } + let_it_be(:shared_project_1) { create(:project, :public, path: '3', name: 'e') } + let_it_be(:shared_project_2) { create(:project, :private, path: '4', name: 'd') } + let_it_be(:shared_project_3) { create(:project, :internal, path: '5', name: 'c') } + let_it_be(:subgroup_project) { create(:project, :public, path: '6', group: subgroup, name: 'b') } + let_it_be(:subgroup_private_project) { create(:project, :private, path: '7', group: subgroup, name: 'a') } before do shared_project_1.project_group_links.create!(group_access: Gitlab::Access::MAINTAINER, group: group) diff --git a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb index 88c31bf9cfd..4c003dff947 100644 --- a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb @@ -54,19 +54,19 @@ RSpec.shared_context 'MergeRequestsFinder multiple projects with merge requests let!(:label2) { create(:label, project: project1) } let!(:merge_request1) do - create(:merge_request, assignees: [user], author: user, + create(:merge_request, assignees: [user], author: user, reviewers: [user2], source_project: project2, target_project: project1, target_branch: 'merged-target') end let!(:merge_request2) do - create(:merge_request, :conflict, assignees: [user], author: user, + create(:merge_request, :conflict, assignees: [user], author: user, reviewers: [user2], source_project: project2, target_project: project1, state: 'closed') end let!(:merge_request3) do - create(:merge_request, :simple, author: user, assignees: [user2], + create(:merge_request, :simple, author: user, assignees: [user2], reviewers: [user], source_project: project2, target_project: project2, state: 'locked', title: 'thing WIP thing') diff --git a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb index 79fc42e73c7..0fee170a35d 100644 --- a/spec/support/shared_contexts/issuable/merge_request_shared_context.rb +++ b/spec/support/shared_contexts/issuable/merge_request_shared_context.rb @@ -1,8 +1,35 @@ # frozen_string_literal: true -RSpec.shared_context 'merge request show action' do +RSpec.shared_context 'open merge request show action' do + include Spec::Support::Helpers::Features::MergeRequestHelpers + + let(:user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + let(:note) { create(:note_on_merge_request, project: project, noteable: open_merge_request) } + + let(:open_merge_request) do + create(:merge_request, :opened, source_project: project, author: user) + end + + before do + assign(:project, project) + assign(:merge_request, open_merge_request) + assign(:note, note) + assign(:noteable, open_merge_request) + assign(:notes, []) + assign(:pipelines, Ci::Pipeline.none) + assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, open_merge_request)) + + preload_view_requirements(open_merge_request, note) + + sign_in(user) + end +end + +RSpec.shared_context 'closed merge request show action' do include Devise::Test::ControllerHelpers include ProjectForksHelper + include Spec::Support::Helpers::Features::MergeRequestHelpers let(:user) { create(:user) } let(:project) { create(:project, :public, :repository) } @@ -17,13 +44,6 @@ RSpec.shared_context 'merge request show action' do author: user) end - def preload_view_requirements - # This will load the status fields of the author of the note and merge request - # to avoid queries in when rendering the view being tested. - closed_merge_request.author.status - note.author.status - end - before do assign(:project, project) assign(:merge_request, closed_merge_request) @@ -34,16 +54,10 @@ RSpec.shared_context 'merge request show action' do assign(:pipelines, Ci::Pipeline.none) assign(:issuable_sidebar, serialize_issuable_sidebar(user, project, closed_merge_request)) - preload_view_requirements + preload_view_requirements(closed_merge_request, note) allow(view).to receive_messages(current_user: user, can?: true, current_application_settings: Gitlab::CurrentSettings.current_application_settings) end - - def serialize_issuable_sidebar(user, project, merge_request) - MergeRequestSerializer - .new(current_user: user, project: project) - .represent(closed_merge_request, serializer: 'sidebar') - end end diff --git a/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb index 3830b89f1ff..5d65c168d8d 100644 --- a/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb +++ b/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb @@ -25,7 +25,7 @@ RSpec.shared_context 'with a mocked GitLab instance' do let(:request) { Rack::MockRequest.new(rack_stack) } subject do - described_class.new(fake_app).tap do |app| + Gitlab::Middleware::ReadOnly.new(fake_app).tap do |app| app.extend(observe_env) end end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index ed74c3f179f..549dc1cff1d 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -14,6 +14,15 @@ RSpec.shared_context 'project navbar structure' do } end + let(:security_and_compliance_nav_item) do + { + nav_item: _('Security & Compliance'), + nav_sub_items: [ + _('Audit Events') + ] + } + end + let(:structure) do [ { @@ -62,6 +71,7 @@ RSpec.shared_context 'project navbar structure' do _('Schedules') ] }, + (security_and_compliance_nav_item if Gitlab.ee?), { nav_item: _('Operations'), nav_sub_items: [ @@ -101,8 +111,7 @@ RSpec.shared_context 'project navbar structure' do _('Access Tokens'), _('Repository'), _('CI / CD'), - _('Operations'), - (_('Audit Events') if Gitlab.ee?) + _('Operations') ].compact } ].compact @@ -128,8 +137,7 @@ RSpec.shared_context 'group navbar structure' do _('Projects'), _('Repository'), _('CI / CD'), - _('Webhooks'), - _('Audit Events') + _('Webhooks') ] } end @@ -143,6 +151,15 @@ RSpec.shared_context 'group navbar structure' do } end + let(:security_and_compliance_nav_item) do + { + nav_item: _('Security & Compliance'), + nav_sub_items: [ + _('Audit Events') + ] + } + end + let(:push_rules_nav_item) do { nav_item: _('Push Rules'), @@ -172,6 +189,7 @@ RSpec.shared_context 'group navbar structure' do nav_item: _('Merge Requests'), nav_sub_items: [] }, + (security_and_compliance_nav_item if Gitlab.ee?), (push_rules_nav_item if Gitlab.ee?), { nav_item: _('Kubernetes'), diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index af46e5474b0..e0e2a18cdd2 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -30,6 +30,7 @@ RSpec.shared_context 'GroupPolicy context' do let(:owner_permissions) do [ + :owner_access, :admin_group, :admin_namespace, :admin_group_member, diff --git a/spec/support/shared_contexts/services_shared_context.rb b/spec/support/shared_contexts/services_shared_context.rb index 2bd516a2339..320f7564cf9 100644 --- a/spec/support/shared_contexts/services_shared_context.rb +++ b/spec/support/shared_contexts/services_shared_context.rb @@ -16,6 +16,8 @@ Service.available_services_names.each do |service| hash.merge!(k => 'secrettoken') elsif service == 'confluence' && k == :confluence_url hash.merge!(k => 'https://example.atlassian.net/wiki') + elsif service == 'datadog' && k == :datadog_site + hash.merge!(k => 'datadoghq.com') elsif k =~ /^(.*_url|url|webhook)/ hash.merge!(k => "http://example.com") elsif service_klass.method_defined?("#{k}?") @@ -34,8 +36,7 @@ Service.available_services_names.each do |service| let(:licensed_features) do { - 'github' => :github_project_service_integration, - 'jenkins' => :jenkins_integration + 'github' => :github_project_service_integration } end diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb index 38a5ed244c4..f89d52f81ad 100644 --- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb +++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb @@ -85,7 +85,7 @@ RSpec.shared_examples 'multiple issue boards' do wait_for_requests - expect(page).to have_selector('.board', count: 5) + expect(page).to have_selector('.board', count: 3) in_boards_switcher_dropdown do click_link board.name @@ -93,7 +93,7 @@ RSpec.shared_examples 'multiple issue boards' do wait_for_requests - expect(page).to have_selector('.board', count: 4) + expect(page).to have_selector('.board', count: 2) end it 'maintains sidebar state over board switch' do diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb index 5a4322f73b6..422282da4d8 100644 --- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb @@ -219,7 +219,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'returns 200 response when the project is imported successfully' do allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -233,7 +233,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do project.errors.add(:path, 'is old') allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -244,7 +244,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it "touches the etag cache store" do allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) expect_next_instance_of(Gitlab::EtagCaching::Store) do |store| expect(store).to receive(:touch) { "realtime_changes_import_#{provider}_path" } @@ -257,7 +257,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do context "when the provider user and GitLab user's usernames match" do it "takes the current user's namespace" do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -269,7 +269,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it "takes the current user's namespace" do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -296,7 +296,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it "takes the existing namespace" do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, existing_namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, existing_namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -308,7 +308,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do create(:user, username: other_username) expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -327,7 +327,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it "takes the new namespace" do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, an_instance_of(Group), user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, an_instance_of(Group), user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: provider_repo.name }, format: :json @@ -348,7 +348,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it "takes the current user's namespace" do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, format: :json @@ -366,7 +366,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'takes the selected namespace and name' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, test_namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, test_namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: test_namespace.name, new_name: test_name }, format: :json @@ -374,7 +374,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'takes the selected name and default namespace' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { new_name: test_name }, format: :json @@ -393,7 +393,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'takes the selected namespace and name' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, nested_namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, nested_namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: nested_namespace.full_path, new_name: test_name }, format: :json @@ -405,7 +405,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'takes the selected namespace and name' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo/bar', new_name: test_name }, format: :json @@ -413,7 +413,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'creates the namespaces' do allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, type: provider, **access_params) .and_return(double(execute: project)) expect { post :create, params: { target_namespace: 'foo/bar', new_name: test_name }, format: :json } @@ -422,7 +422,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'new namespace has the right parent' do allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo/bar', new_name: test_name }, format: :json @@ -441,7 +441,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'takes the selected namespace and name' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :json @@ -449,7 +449,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'creates the namespaces' do allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, type: provider, **access_params) .and_return(double(execute: project)) expect { post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :json } @@ -458,7 +458,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'does not create a new namespace under the user namespace' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) expect { post :create, params: { target_namespace: "#{user.namespace_path}/test_group", new_name: test_name }, format: :js } @@ -472,7 +472,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'does not take the selected namespace and name' do expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, user.namespace, user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, user.namespace, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js @@ -480,7 +480,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do it 'does not create the namespaces' do allow(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, kind_of(Namespace), user, type: provider, **access_params) .and_return(double(execute: project)) expect { post :create, params: { target_namespace: 'foo/foobar/bar', new_name: test_name }, format: :js } @@ -497,7 +497,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST create' do user.update!(can_create_group: false) expect(Gitlab::LegacyGithubImport::ProjectCreator) - .to receive(:new).with(provider_repo, test_name, group, user, access_params, type: provider) + .to receive(:new).with(provider_repo, test_name, group, user, type: provider, **access_params) .and_return(double(execute: project)) post :create, params: { target_namespace: 'foo', new_name: test_name }, format: :js diff --git a/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb b/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb new file mode 100644 index 00000000000..9b738a4b002 --- /dev/null +++ b/spec/support/shared_examples/controllers/repositories/git_http_controller_shared_examples.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +RSpec.shared_examples Repositories::GitHttpController do + include GitHttpHelpers + + let(:repository_path) { "#{container.full_path}.git" } + let(:params) { { repository_path: repository_path } } + + describe 'HEAD #info_refs' do + it 'returns 403' do + head :info_refs, params: params + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'GET #info_refs' do + let(:params) { super().merge(service: 'git-upload-pack') } + + it 'returns 401 for unauthenticated requests to public repositories when http protocol is disabled' do + stub_application_setting(enabled_git_access_protocol: 'ssh') + allow(controller).to receive(:basic_auth_provided?).and_call_original + + expect(controller).to receive(:http_download_allowed?).and_call_original + + get :info_refs, params: params + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'calls the right access checker class with the right object' do + allow(controller).to receive(:verify_workhorse_api!).and_return(true) + + access_double = double + options = { + authentication_abilities: [:download_code], + repository_path: repository_path, + redirected_path: nil, + auth_result_type: :none + } + + expect(access_checker_class).to receive(:new) + .with(nil, container, 'http', hash_including(options)) + .and_return(access_double) + + allow(access_double).to receive(:check).and_return(false) + + get :info_refs, params: params + end + + context 'with authorized user' do + before do + request.headers.merge! auth_env(user.username, user.password, nil) + end + + it 'returns 200' do + get :info_refs, params: params + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'updates the user activity' do + expect_next_instance_of(Users::ActivityService) do |activity_service| + expect(activity_service).to receive(:execute) + end + + get :info_refs, params: params + end + + include_context 'parsed logs' do + it 'adds user info to the logs' do + get :info_refs, params: params + + expect(log_data).to include('username' => user.username, + 'user_id' => user.id, + 'meta.user' => user.username) + end + end + end + + context 'with exceptions' do + before do + allow(controller).to receive(:authenticate_user).and_return(true) + allow(controller).to receive(:verify_workhorse_api!).and_return(true) + end + + it 'returns 503 with GRPC Unavailable' do + allow(controller).to receive(:access_check).and_raise(GRPC::Unavailable) + + get :info_refs, params: params + + expect(response).to have_gitlab_http_status(:service_unavailable) + end + + it 'returns 503 with timeout error' do + allow(controller).to receive(:access_check).and_raise(Gitlab::GitAccess::TimeoutError) + + get :info_refs, params: params + + expect(response).to have_gitlab_http_status(:service_unavailable) + expect(response.body).to eq 'Gitlab::GitAccess::TimeoutError' + end + end + end + + describe 'POST #git_upload_pack' do + before do + allow(controller).to receive(:verify_workhorse_api!).and_return(true) + end + + it 'returns 200' do + post :git_upload_pack, params: params + + expect(response).to have_gitlab_http_status(:ok) + end + end +end 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 a6ad8fc594c..dcbf494186a 100644 --- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb +++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb @@ -14,6 +14,22 @@ RSpec.shared_examples 'wiki controller actions' do sign_in(user) end + shared_examples 'recovers from git timeout' do + let(:method_name) { :page } + + context 'when we encounter git command errors' do + it 'renders the appropriate template', :aggregate_failures do + expect(controller).to receive(method_name) do + raise ::Gitlab::Git::CommandTimedOut, 'Deadline Exceeded' + end + + request + + expect(response).to render_template('shared/wikis/git_error') + end + end + end + describe 'GET #new' do subject(:request) { get :new, params: routing_params } @@ -48,6 +64,12 @@ RSpec.shared_examples 'wiki controller actions' do get :pages, params: routing_params.merge(id: wiki_title) end + it_behaves_like 'recovers from git timeout' do + subject(:request) { get :pages, params: routing_params.merge(id: wiki_title) } + + let(:method_name) { :wiki_pages } + end + it 'assigns the page collections' do expect(assigns(:wiki_pages)).to contain_exactly(an_instance_of(WikiPage)) expect(assigns(:wiki_entries)).to contain_exactly(an_instance_of(WikiPage)) @@ -99,6 +121,12 @@ RSpec.shared_examples 'wiki controller actions' do end end + it_behaves_like 'recovers from git timeout' do + subject(:request) { get :history, params: routing_params.merge(id: wiki_title) } + + let(:allow_read_wiki) { true } + end + it_behaves_like 'fetching history', :ok do let(:allow_read_wiki) { true } @@ -139,6 +167,10 @@ RSpec.shared_examples 'wiki controller actions' do expect(response).to have_gitlab_http_status(:not_found) end end + + it_behaves_like 'recovers from git timeout' do + subject(:request) { get :diff, params: routing_params.merge(id: wiki_title, version_id: wiki.repository.commit.id) } + end end describe 'GET #show' do @@ -151,6 +183,8 @@ RSpec.shared_examples 'wiki controller actions' do context 'when page exists' do let(:id) { wiki_title } + it_behaves_like 'recovers from git timeout' + it 'renders the page' do request @@ -161,6 +195,28 @@ RSpec.shared_examples 'wiki controller actions' do expect(assigns(:sidebar_limited)).to be(false) end + context 'the sidebar fails to load' do + before do + allow(Wiki).to receive(:for_container).and_return(wiki) + wiki.wiki + expect(wiki).to receive(:find_sidebar) do + raise ::Gitlab::Git::CommandTimedOut, 'Deadline Exceeded' + end + end + + it 'renders the page, and marks the sidebar as failed' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('shared/wikis/_sidebar') + expect(assigns(:page).title).to eq(wiki_title) + expect(assigns(:sidebar_page)).to be_nil + expect(assigns(:sidebar_wiki_entries)).to be_nil + expect(assigns(:sidebar_limited)).to be_nil + expect(assigns(:sidebar_error)).to be_a_kind_of(::Gitlab::Git::CommandError) + end + end + context 'page view tracking' do it_behaves_like 'tracking unique hll events', :track_unique_wiki_page_views do let(:target_id) { 'wiki_action' } @@ -308,6 +364,7 @@ RSpec.shared_examples 'wiki controller actions' do subject(:request) { get(:edit, params: routing_params.merge(id: id_param)) } it_behaves_like 'edit action' + it_behaves_like 'recovers from git timeout' context 'when page content encoding is valid' do render_views @@ -447,6 +504,17 @@ RSpec.shared_examples 'wiki controller actions' do end end + describe '#git_access' do + render_views + + it 'renders the git access page' do + get :git_access, params: routing_params + + expect(response).to render_template('shared/wikis/git_access') + expect(response.body).to include(wiki.http_url_to_repo) + end + end + def redirect_to_wiki(wiki, page) redirect_to(controller.wiki_page_path(wiki, page)) end diff --git a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb index ac1cc2da7e3..3fec1a56c0c 100644 --- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb +++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb @@ -3,7 +3,7 @@ RSpec.shared_examples 'issuable invite members experiments' do context 'when invite_members_version_a experiment is enabled' do before do - stub_experiment_for_user(invite_members_version_a: true) + stub_experiment_for_subject(invite_members_version_a: true) end it 'shows a link for inviting members and follows through to the members page' do @@ -28,7 +28,7 @@ RSpec.shared_examples 'issuable invite members experiments' do context 'when invite_members_version_b experiment is enabled' do before do - stub_experiment_for_user(invite_members_version_b: true) + stub_experiment_for_subject(invite_members_version_b: true) end it 'shows a link for inviting members and follows through to modal' do diff --git a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb index 724d6db2705..1dbaace1c89 100644 --- a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb +++ b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb @@ -50,7 +50,6 @@ RSpec.shared_examples 'Maintainer manages access requests' do def expect_visible_access_request(entity, user) if has_tabs expect(page).to have_content "Access requests 1" - expect(page).to have_content "Users requesting access to #{entity.name}" else expect(page).to have_content "Users requesting access to #{entity.name} 1" end diff --git a/spec/support/shared_examples/features/reportable_note_shared_examples.rb b/spec/support/shared_examples/features/reportable_note_shared_examples.rb index bdaa375721f..288e1df9b2a 100644 --- a/spec/support/shared_examples/features/reportable_note_shared_examples.rb +++ b/spec/support/shared_examples/features/reportable_note_shared_examples.rb @@ -29,7 +29,7 @@ RSpec.shared_examples 'reportable note' do |type| end end - it 'Report button links to a report page' do + it 'report button links to a report page' do dropdown = comment.find(more_actions_selector) open_dropdown(dropdown) diff --git a/spec/support/shared_examples/features/wiki/user_git_access_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_git_access_wiki_page_shared_examples.rb new file mode 100644 index 00000000000..d3d2a36147d --- /dev/null +++ b/spec/support/shared_examples/features/wiki/user_git_access_wiki_page_shared_examples.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'User views Git access wiki page' do + let(:wiki_page) { create(:wiki_page, wiki: wiki) } + + before do + sign_in(user) + end + + it 'shows the correct clone URLs', :js do + visit wiki_page_path(wiki, wiki_page) + click_link 'Clone repository' + + expect(page).to have_text("Clone repository #{wiki.full_path}") + + within('.git-clone-holder') do + expect(page).to have_css('#clone-dropdown', text: 'HTTP') + expect(page).to have_field('clone_url', with: wiki.http_url_to_repo) + + click_link 'HTTP' # open the dropdown + click_link 'SSH' # select the dropdown item + + expect(page).to have_css('#clone-dropdown', text: 'SSH') + expect(page).to have_field('clone_url', with: wiki.ssh_url_to_repo) + end + end +end diff --git a/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb index 0330b345a18..759cfaf6b1f 100644 --- a/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb +++ b/spec/support/shared_examples/features/wiki/user_uses_wiki_shortcuts_shared_examples.rb @@ -12,7 +12,7 @@ RSpec.shared_examples 'User uses wiki shortcuts' do visit wiki_page_path(wiki, wiki_page) end - it 'Visit edit wiki page using "e" keyboard shortcut', :js do + it 'visit edit wiki page using "e" keyboard shortcut', :js do find('body').native.send_key('e') expect(find('.wiki-page-title')).to have_content('Edit Page') diff --git a/spec/support/shared_examples/graphql/connection_redaction_shared_examples.rb b/spec/support/shared_examples/graphql/connection_redaction_shared_examples.rb new file mode 100644 index 00000000000..12a7b3fe414 --- /dev/null +++ b/spec/support/shared_examples/graphql/connection_redaction_shared_examples.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# requires: +# - `connection` (no-empty, containing `unwanted` and at least one more item) +# - `unwanted` (single item in collection) +RSpec.shared_examples 'a redactable connection' do + context 'no redactor set' do + it 'contains the unwanted item' do + expect(connection.nodes).to include(unwanted) + end + + it 'does not redact more than once' do + connection.nodes + r_state = connection.send(:redaction_state) + + expect(r_state.redacted { raise 'Should not be called!' }).to be_present + end + end + + let_it_be(:constant_redactor) do + Class.new do + def initialize(remove) + @remove = remove + end + + def redact(items) + items - @remove + end + end + end + + context 'redactor is set' do + let(:redactor) do + constant_redactor.new([unwanted]) + end + + before do + connection.redactor = redactor + end + + it 'does not contain the unwanted item' do + expect(connection.nodes).not_to include(unwanted) + expect(connection.nodes).not_to be_empty + end + + it 'does not redact more than once' do + expect(redactor).to receive(:redact).once.and_call_original + + connection.nodes + connection.nodes + connection.nodes + end + end +end diff --git a/spec/support/shared_examples/graphql/connection_shared_examples.rb b/spec/support/shared_examples/graphql/connection_shared_examples.rb new file mode 100644 index 00000000000..4cba5b5a69d --- /dev/null +++ b/spec/support/shared_examples/graphql/connection_shared_examples.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a connection with collection methods' do + %i[to_a size include? empty?].each do |method_name| + it "responds to #{method_name}" do + expect(connection).to respond_to(method_name) + end + end +end diff --git a/spec/support/shared_examples/graphql/design_fields_shared_examples.rb b/spec/support/shared_examples/graphql/design_fields_shared_examples.rb index ef7086234c4..9c2eb3e5a5c 100644 --- a/spec/support/shared_examples/graphql/design_fields_shared_examples.rb +++ b/spec/support/shared_examples/graphql/design_fields_shared_examples.rb @@ -26,27 +26,35 @@ RSpec.shared_examples 'a GraphQL type with design fields' do end describe '#image' do + let_it_be(:current_user) { create(:user) } let(:schema) { GitlabSchema } let(:query) { GraphQL::Query.new(schema) } - let(:context) { double('Context', schema: schema, query: query, parent: nil) } + let(:context) { query.context } let(:field) { described_class.fields['image'] } let(:args) { GraphQL::Query::Arguments::NO_ARGS } - let(:instance) do + let(:instance) { instantiate(object_id) } + let(:instance_b) { instantiate(object_id_b) } + + def instantiate(object_id) object = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id)) object_type.authorized_new(object, query.context) end - let(:instance_b) do - object_b = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id_b)) - object_type.authorized_new(object_b, query.context) + def resolve_image(instance) + field.resolve_field(instance, args, context) + end + + before do + context[:current_user] = current_user + allow(Ability).to receive(:allowed?).with(current_user, :read_design, anything).and_return(true) + allow(context).to receive(:parent).and_return(nil) end it 'resolves to the design image URL' do - image = field.resolve_field(instance, args, context) sha = design.versions.first.sha url = ::Gitlab::Routing.url_helpers.project_design_management_designs_raw_image_url(design.project, design, sha) - expect(image).to eq(url) + expect(resolve_image(instance)).to eq(url) end it 'has better than O(N) peformance', :request_store do @@ -68,10 +76,10 @@ RSpec.shared_examples 'a GraphQL type with design fields' do # = 10 expect(instance).not_to eq(instance_b) # preload designs themselves. expect do - image_a = field.resolve_field(instance, args, context) - image_b = field.resolve_field(instance, args, context) - image_c = field.resolve_field(instance_b, args, context) - image_d = field.resolve_field(instance_b, args, context) + image_a = resolve_image(instance) + image_b = resolve_image(instance) + image_c = resolve_image(instance_b) + image_d = resolve_image(instance_b) expect(image_a).to eq(image_b) expect(image_c).not_to eq(image_b) expect(image_c).to eq(image_d) diff --git a/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb b/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb deleted file mode 100644 index b2047f1d32c..00000000000 --- a/spec/support/shared_examples/graphql/jira_import/jira_import_resolver_shared_examples.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'no Jira import data present' do - it 'returns none' do - expect(resolve_imports).to eq JiraImportState.none - end -end - -RSpec.shared_examples 'no Jira import access' do - it 'raises error' do - expect do - resolve_imports - end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) - end -end diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb index 3a046c3feec..b0bdd27a95f 100644 --- a/spec/support/shared_examples/graphql/members_shared_examples.rb +++ b/spec/support/shared_examples/graphql/members_shared_examples.rb @@ -36,9 +36,10 @@ RSpec.shared_examples 'querying members with a group' do let_it_be(:group_2_member) { create(:group_member, user: user_3, group: group_2) } let(:args) { {} } + let(:base_args) { { relations: described_class.arguments['relations'].default_value } } subject do - resolve(described_class, obj: resource, args: args, ctx: { current_user: user_4 }) + resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: user_4 }) end describe '#resolve' do @@ -72,7 +73,7 @@ RSpec.shared_examples 'querying members with a group' do let_it_be(:other_user) { create(:user) } subject do - resolve(described_class, obj: resource, args: args, ctx: { current_user: other_user }) + resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: other_user }) end it 'raises an error' do diff --git a/spec/support/shared_examples/graphql/mutation_shared_examples.rb b/spec/support/shared_examples/graphql/mutation_shared_examples.rb index b67cac94547..84ebd4852b9 100644 --- a/spec/support/shared_examples/graphql/mutation_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutation_shared_examples.rb @@ -13,6 +13,8 @@ RSpec.shared_examples 'a mutation that returns top-level errors' do |errors: []| it do post_graphql_mutation(mutation, current_user: current_user) + expect(graphql_errors).to be_present + error_messages = graphql_errors.map { |e| e['message'] } expect(error_messages).to match_errors diff --git a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb index 9c0b398a5c1..2b93d174653 100644 --- a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb @@ -40,6 +40,30 @@ RSpec.shared_examples 'boards create mutation' do end end + context 'when hide_backlog_list parameter is true' do + before do + params[:hide_backlog_list] = true + end + + it 'returns the board with correct hide_backlog_list field' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['board']['hideBacklogList']).to eq(true) + end + end + + context 'when hide_closed_list parameter is true' do + before do + params[:hide_closed_list] = true + end + + it 'returns the board with correct hide_closed_list field' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['board']['hideClosedList']).to eq(true) + end + end + context 'when the Boards::CreateService returns an error response' do before do allow_next_instance_of(Boards::CreateService) do |service| diff --git a/spec/support/shared_examples/graphql/mutations/spammable_mutation_fields_examples.rb b/spec/support/shared_examples/graphql/mutations/spammable_mutation_fields_examples.rb index 54b3f84a6e6..8678b23ad31 100644 --- a/spec/support/shared_examples/graphql/mutations/spammable_mutation_fields_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/spammable_mutation_fields_examples.rb @@ -13,7 +13,8 @@ end RSpec.shared_examples 'can raise spam flag' do it 'spam parameters are passed to the service' do - expect(service).to receive(:new).with(anything, anything, hash_including(api: true, request: instance_of(ActionDispatch::Request))) + args = [anything, anything, hash_including(api: true, request: instance_of(ActionDispatch::Request))] + expect(service).to receive(:new).with(*args).and_call_original subject end @@ -39,7 +40,9 @@ RSpec.shared_examples 'can raise spam flag' do end it 'request parameter is not passed to the service' do - expect(service).to receive(:new).with(anything, anything, hash_not_including(request: instance_of(ActionDispatch::Request))) + expect(service).to receive(:new) + .with(anything, anything, hash_not_including(request: instance_of(ActionDispatch::Request))) + .and_call_original subject end diff --git a/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb b/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb index 94b7ed1618d..16c2ab07f3a 100644 --- a/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb +++ b/spec/support/shared_examples/graphql/projects/services_resolver_shared_examples.rb @@ -2,14 +2,12 @@ RSpec.shared_examples 'no project services' do it 'returns empty collection' do - expect(resolve_services).to eq [] + expect(resolve_services).to be_empty end end RSpec.shared_examples 'cannot access project services' do it 'raises error' do - expect do - resolve_services - end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect(resolve_services).to be_nil end end 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 7627a7b4d59..f78ea364147 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 @@ -16,80 +16,111 @@ # # Example: # describe 'sorting and pagination' do -# let(:sort_project) { create(:project, :public) } +# let_it_be(:sort_project) { create(:project, :public) } # let(:data_path) { [:project, :issues] } # -# def pagination_query(params, page_info) -# graphql_query_for( -# 'project', -# { 'fullPath' => sort_project.full_path }, -# query_graphql_field('issues', params, "#{page_info} edges { node { id } }") +# def pagination_query(arguments) +# graphql_query_for(:project, { full_path: sort_project.full_path }, +# query_nodes(:issues, :iid, include_pagination_info: true, args: arguments) # ) # end # -# def pagination_results_data(data) -# data.map { |issue| issue.dig('node', 'iid').to_i } +# # A method transforming nodes to data to match against +# # default: the identity function +# def pagination_results_data(issues) +# issues.map { |issue| issue['iid].to_i } # end # # context 'when sorting by weight' do -# ... +# let_it_be(:issues) { make_some_issues_with_weights } +# # context 'when ascending' do +# let(:ordered_issues) { issues.sort_by(&:weight) } +# # it_behaves_like 'sorted paginated query' do -# let(:sort_param) { 'WEIGHT_ASC' } +# let(:sort_param) { :WEIGHT_ASC } # let(:first_param) { 2 } -# let(:expected_results) { [weight_issue3.iid, weight_issue5.iid, weight_issue1.iid, weight_issue4.iid, weight_issue2.iid] } +# let(:expected_results) { ordered_issues.map(&:iid) } # end # end # RSpec.shared_examples 'sorted paginated query' do + # Provided as a convenience when constructing queries using string concatenation + let(:page_info) { 'pageInfo { startCursor endCursor }' } + # Convenience for using default implementation of pagination_results_data + let(:node_path) { ['id'] } + it_behaves_like 'requires variables' do let(:required_variables) { [:sort_param, :first_param, :expected_results, :data_path, :current_user] } end describe do - let(:sort_argument) { "sort: #{sort_param}" if sort_param.present? } - let(:first_argument) { "first: #{first_param}" if first_param.present? } + let(:sort_argument) { graphql_args(sort: sort_param) } let(:params) { sort_argument } - let(:start_cursor) { graphql_data_at(*data_path, :pageInfo, :startCursor) } - let(:end_cursor) { graphql_data_at(*data_path, :pageInfo, :endCursor) } - let(:sorted_edges) { graphql_data_at(*data_path, :edges) } - let(:page_info) { "pageInfo { startCursor endCursor }" } - def pagination_query(params, page_info) - raise('pagination_query(params, page_info) must be defined in the test, see example in comment') unless defined?(super) + # Convenience helper for the large number of queries defined as a projection + # from some root value indexed by full_path to a collection of objects with IID + def nested_internal_id_query(root_field, parent, field, args, selection: :iid) + graphql_query_for(root_field, { full_path: parent.full_path }, + query_nodes(field, selection, args: args, include_pagination_info: true) + ) + end + + def pagination_query(params) + raise('pagination_query(params) must be defined in the test, see example in comment') unless defined?(super) super end - def pagination_results_data(data) - raise('pagination_results_data(data) must be defined in the test, see example in comment') unless defined?(super) + def pagination_results_data(nodes) + if defined?(super) + super(nodes) + else + nodes.map { |n| n.dig(*node_path) } + end + end + + def results + nodes = graphql_dig_at(graphql_data(fresh_response_data), *data_path, :nodes) + pagination_results_data(nodes) + end + + def end_cursor + graphql_dig_at(graphql_data(fresh_response_data), *data_path, :page_info, :end_cursor) + end - super(data) + def start_cursor + graphql_dig_at(graphql_data(fresh_response_data), *data_path, :page_info, :start_cursor) end + let(:query) { pagination_query(params) } + before do - post_graphql(pagination_query(params, page_info), current_user: current_user) + post_graphql(query, current_user: current_user) end context 'when sorting' do it 'sorts correctly' do - expect(pagination_results_data(sorted_edges)).to eq expected_results + expect(results).to eq expected_results end context 'when paginating' do - let(:params) { [sort_argument, first_argument].compact.join(',') } + let(:params) { sort_argument.merge(first: first_param) } + let(:first_page) { expected_results.first(first_param) } + let(:rest) { expected_results.drop(first_param) } it 'paginates correctly' do - expect(pagination_results_data(sorted_edges)).to eq expected_results.first(first_param) + expect(results).to eq first_page - cursored_query = pagination_query([sort_argument, "after: \"#{end_cursor}\""].compact.join(','), page_info) - post_graphql(cursored_query, current_user: current_user) + fwds = pagination_query(sort_argument.merge(after: end_cursor)) + post_graphql(fwds, current_user: current_user) - expect(response).to have_gitlab_http_status(:ok) + expect(results).to eq rest - response_data = graphql_dig_at(Gitlab::Json.parse(response.body), :data, *data_path, :edges) + bwds = pagination_query(sort_argument.merge(before: start_cursor)) + post_graphql(bwds, current_user: current_user) - expect(pagination_results_data(response_data)).to eq expected_results.drop(first_param) + expect(results).to eq first_page end end end 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 index ed139e638bf..269e9170906 100644 --- 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 @@ -32,16 +32,16 @@ RSpec.shared_examples 'Gitlab-style deprecations' do 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') + 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' + description: 'Deprecable description.' ) - expect(deprecable.description).to eq('Deprecable description. Deprecated in 1.10: Deprecation reason') + 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 diff --git a/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb index 469c0c287b1..c9e03ced0dd 100644 --- a/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/diff_file_collections_shared_examples.rb @@ -143,3 +143,55 @@ RSpec.shared_examples 'cacheable diff collection' do end end end + +shared_examples_for 'sortable diff files' do + subject { described_class.new(diffable, **collection_default_args) } + + describe '#raw_diff_files' do + let(:raw_diff_files_paths) do + subject.raw_diff_files(sorted: sorted).map { |file| file.new_path.presence || file.old_path } + end + + context 'when sorted is false (default)' do + let(:sorted) { false } + + it 'returns unsorted diff files' do + expect(raw_diff_files_paths).to eq(unsorted_diff_files_paths) + end + end + + context 'when sorted is true' do + let(:sorted) { true } + + it 'returns sorted diff files' do + expect(raw_diff_files_paths).to eq(sorted_diff_files_paths) + end + + context 'when sort_diffs feature flag is disabled' do + before do + stub_feature_flags(sort_diffs: false) + end + + it 'returns unsorted diff files' do + expect(raw_diff_files_paths).to eq(unsorted_diff_files_paths) + end + end + end + end +end + +shared_examples_for 'unsortable diff files' do + subject { described_class.new(diffable, **collection_default_args) } + + describe '#raw_diff_files' do + it 'does not call Gitlab::Diff::FileCollectionSorter even when sorted is true' do + # Ensure that diffable is created before expectation to ensure that we are + # not calling it from `FileCollectionSorter` from `#raw_diff_files`. + diffable + + expect(Gitlab::Diff::FileCollectionSorter).not_to receive(:new) + + subject.raw_diff_files(sorted: true) + end + end +end diff --git a/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb index 801be5ae946..67afd2035c4 100644 --- a/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb @@ -3,10 +3,10 @@ RSpec.shared_examples 'log import failure' do |importable_column| it 'tracks error' do extra = { - source: action, - relation_key: relation_key, - relation_index: relation_index, - retry_count: retry_count + source: action, + relation_name: relation_key, + relation_index: relation_index, + retry_count: retry_count } extra[importable_column] = importable.id diff --git a/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb index e07d3e2dec9..5b3d30df739 100644 --- a/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb @@ -125,6 +125,9 @@ RSpec.shared_examples 'write access for a read-only GitLab instance' do where(:description, :path) do 'LFS request to batch' | '/root/rouge.git/info/lfs/objects/batch' 'request to git-upload-pack' | '/root/rouge.git/git-upload-pack' + 'user sign out' | '/users/sign_out' + 'admin session' | '/admin/session' + 'admin session destroy' | '/admin/session/destroy' end with_them do diff --git a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb index 2936bb354cf..89b793d5e16 100644 --- a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb @@ -38,7 +38,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| it 'adds the jid of the existing job to the job hash' do allow(fake_duplicate_job).to receive(:scheduled?).and_return(false) allow(fake_duplicate_job).to receive(:check!).and_return('the jid') - allow(fake_duplicate_job).to receive(:droppable?).and_return(true) + allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) allow(fake_duplicate_job).to receive(:options).and_return({}) job_hash = {} @@ -62,7 +62,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| receive(:check!) .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL) .and_return('the jid')) - allow(fake_duplicate_job).to receive(:droppable?).and_return(true) + allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) job_hash = {} expect(fake_duplicate_job).to receive(:duplicate?).and_return(true) @@ -82,7 +82,7 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true }) allow(fake_duplicate_job).to( receive(:check!).with(time_diff.to_i).and_return('the jid')) - allow(fake_duplicate_job).to receive(:droppable?).and_return(true) + allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) job_hash = {} expect(fake_duplicate_job).to receive(:duplicate?).and_return(true) @@ -104,13 +104,13 @@ RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name| allow(fake_duplicate_job).to receive(:duplicate?).and_return(true) allow(fake_duplicate_job).to receive(:options).and_return({}) allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid') - allow(fake_duplicate_job).to receive(:droppable?).and_return(true) + allow(fake_duplicate_job).to receive(:idempotent?).and_return(true) end it 'drops the job' do schedule_result = nil - expect(fake_duplicate_job).to receive(:droppable?).and_return(true) + expect(fake_duplicate_job).to receive(:idempotent?).and_return(true) expect { |b| schedule_result = strategy.schedule({}, &b) }.not_to yield_control expect(schedule_result).to be(false) diff --git a/spec/support/shared_examples/models/concerns/can_move_repository_storage_shared_examples.rb b/spec/support/shared_examples/models/concerns/can_move_repository_storage_shared_examples.rb new file mode 100644 index 00000000000..85a2c6f1449 --- /dev/null +++ b/spec/support/shared_examples/models/concerns/can_move_repository_storage_shared_examples.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'can move repository storage' do + let(:container) { raise NotImplementedError } + + describe '#set_repository_read_only!' do + it 'makes the repository read-only' do + expect { container.set_repository_read_only! } + .to change(container, :repository_read_only?) + .from(false) + .to(true) + end + + it 'raises an error if the project is already read-only' do + container.set_repository_read_only! + + expect { container.set_repository_read_only! }.to raise_error(described_class::RepositoryReadOnlyError, /already read-only/) + end + + it 'raises an error when there is an existing git transfer in progress' do + allow(container).to receive(:git_transfer_in_progress?) { true } + + expect { container.set_repository_read_only! }.to raise_error(described_class::RepositoryReadOnlyError, /in progress/) + end + + context 'skip_git_transfer_check is true' do + it 'makes the project read-only when git transfers are in progress' do + allow(container).to receive(:git_transfer_in_progress?) { true } + + expect { container.set_repository_read_only!(skip_git_transfer_check: true) } + .to change(container, :repository_read_only?) + .from(false) + .to(true) + end + end + end + + describe '#set_repository_writable!' do + it 'sets repository_read_only to false' do + expect { container.set_repository_writable! } + .to change(container, :repository_read_only) + .from(true).to(false) + end + end + + describe '#reference_counter' do + it 'returns a Gitlab::ReferenceCounter object' do + expect(Gitlab::ReferenceCounter).to receive(:new).with(container.repository.gl_repository).and_call_original + + result = container.reference_counter(type: container.repository.repo_type) + + expect(result).to be_a Gitlab::ReferenceCounter + end + end +end diff --git a/spec/support/shared_examples/models/concerns/repository_storage_movable_shared_examples.rb b/spec/support/shared_examples/models/concerns/repository_storage_movable_shared_examples.rb new file mode 100644 index 00000000000..5a8388d01df --- /dev/null +++ b/spec/support/shared_examples/models/concerns/repository_storage_movable_shared_examples.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handles repository moves' do + describe 'associations' do + it { is_expected.to belong_to(:container) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:container) } + it { is_expected.to validate_presence_of(:state) } + it { is_expected.to validate_presence_of(:source_storage_name) } + it { is_expected.to validate_presence_of(:destination_storage_name) } + + context 'source_storage_name inclusion' do + subject { build(repository_storage_factory_key, source_storage_name: 'missing') } + + it "does not allow repository storages that don't match a label in the configuration" do + expect(subject).not_to be_valid + expect(subject.errors[:source_storage_name].first).to match(/is not included in the list/) + end + end + + context 'destination_storage_name inclusion' do + subject { build(repository_storage_factory_key, destination_storage_name: 'missing') } + + it "does not allow repository storages that don't match a label in the configuration" do + expect(subject).not_to be_valid + expect(subject.errors[:destination_storage_name].first).to match(/is not included in the list/) + end + end + + context 'container repository read-only' do + subject { build(repository_storage_factory_key, container: container) } + + it "does not allow the container to be read-only on create" do + container.update!(repository_read_only: true) + + expect(subject).not_to be_valid + expect(subject.errors[error_key].first).to match(/is read only/) + end + end + end + + describe 'defaults' do + context 'destination_storage_name' do + subject { build(repository_storage_factory_key) } + + it 'picks storage from ApplicationSetting' do + expect(Gitlab::CurrentSettings).to receive(:pick_repository_storage).and_return('picked').at_least(:once) + + expect(subject.destination_storage_name).to eq('picked') + end + end + end + + describe 'state transitions' do + before do + stub_storage_settings('test_second_storage' => { 'path' => 'tmp/tests/extra_storage' }) + end + + context 'when in the default state' do + subject(:storage_move) { create(repository_storage_factory_key, container: container, destination_storage_name: 'test_second_storage') } + + context 'and transits to scheduled' do + it 'triggers the corresponding repository storage worker' do + skip unless repository_storage_worker # TODO remove after https://gitlab.com/gitlab-org/gitlab/-/issues/218991 is implemented + expect(repository_storage_worker).to receive(:perform_async).with(container.id, 'test_second_storage', storage_move.id) + + storage_move.schedule! + + expect(container).to be_repository_read_only + end + + context 'when the transition fails' do + it 'does not trigger ProjectUpdateRepositoryStorageWorker and adds an error' do + skip unless repository_storage_worker # TODO remove after https://gitlab.com/gitlab-org/gitlab/-/issues/218991 is implemented + allow(storage_move.container).to receive(:set_repository_read_only!).and_raise(StandardError, 'foobar') + expect(repository_storage_worker).not_to receive(:perform_async) + + storage_move.schedule! + + expect(storage_move.errors[error_key]).to include('foobar') + end + end + end + + context 'and transits to started' do + it 'does not allow the transition' do + expect { storage_move.start! } + .to raise_error(StateMachines::InvalidTransition) + end + end + end + + context 'when started' do + subject(:storage_move) { create(repository_storage_factory_key, :started, container: container, destination_storage_name: 'test_second_storage') } + + context 'and transits to replicated' do + it 'marks the container as writable' do + storage_move.finish_replication! + + expect(container).not_to be_repository_read_only + end + end + + context 'and transits to failed' do + it 'marks the container as writable' do + storage_move.do_fail! + + expect(container).not_to be_repository_read_only + end + end + end + end +end diff --git a/spec/support/shared_examples/models/concerns/shardable_shared_examples.rb b/spec/support/shared_examples/models/concerns/shardable_shared_examples.rb index fa929d5b791..fd0639b628e 100644 --- a/spec/support/shared_examples/models/concerns/shardable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/shardable_shared_examples.rb @@ -18,4 +18,10 @@ RSpec.shared_examples 'shardable scopes' do expect(described_class.excluding_repository_storage('default')).to eq([record_2]) end end + + describe '.for_shard' do + it 'returns the objects for a given shard' do + expect(described_class.for_shard(record_1.shard)).to eq([record_1]) + end + 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 0ee0b7e6d88..2392658e584 100644 --- a/spec/support/shared_examples/models/mentionable_shared_examples.rb +++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb @@ -92,7 +92,7 @@ RSpec.shared_examples 'a mentionable' do end end - expect(subject).to receive(:cached_markdown_fields).at_least(:once).and_call_original + expect(subject).to receive(:cached_markdown_fields).at_least(1).and_call_original subject.all_references(author) end @@ -151,7 +151,7 @@ RSpec.shared_examples 'an editable mentionable' do end it 'persists the refreshed cache so that it does not have to be refreshed every time' do - expect(subject).to receive(:refresh_markdown_cache).once.and_call_original + expect(subject).to receive(:refresh_markdown_cache).at_least(1).and_call_original subject.all_references(author) diff --git a/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb b/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb index 5198508d48b..f56e8d4e085 100644 --- a/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb +++ b/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb @@ -75,11 +75,17 @@ RSpec.shared_examples 'timebox resource event actions' do end RSpec.shared_examples 'timebox resource tracks issue metrics' do |type| - describe '#usage_metrics' do - it 'tracks usage' do + describe '#issue_usage_metrics' do + it 'tracks usage for issues' do expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:"track_issue_#{type}_changed_action") create(described_class.name.underscore.to_sym, issue: create(:issue)) end + + it 'does not track usage for merge requests' do + expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).not_to receive(:"track_issue_#{type}_changed_action") + + create(described_class.name.underscore.to_sym, merge_request: create(:merge_request)) + end end end diff --git a/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb new file mode 100644 index 00000000000..a99304f7214 --- /dev/null +++ b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'clone quick action' do + context 'clone the issue to another project' do + let(:target_project) { create(:project, :public) } + + context 'when no target is given' do + it 'clones the issue in the current project' do + add_note("/clone") + + expect(page).to have_content "Cloned this issue to #{project.full_path}." + expect(issue.reload).to be_open + + visit project_issue_path(project, issue) + + expect(page).to have_content 'Issues 2' + end + end + + context 'when the project is valid' do + before do + target_project.add_maintainer(user) + end + + it 'clones the issue' do + add_note("/clone #{target_project.full_path}") + + expect(page).to have_content "Cloned this issue to #{target_project.full_path}." + expect(issue.reload).to be_open + + visit project_issue_path(target_project, issue) + + expect(page).to have_content 'Issues 1' + end + + context 'when cloning with notes', :aggregate_failures do + it 'clones the issue with all notes' do + add_note("Some random note") + add_note("Another note") + + add_note("/clone --with_notes #{target_project.full_path}") + + expect(page).to have_content "Cloned this issue to #{target_project.full_path}." + expect(issue.reload).to be_open + + visit project_issue_path(target_project, issue) + + expect(page).to have_content 'Issues 1' + expect(page).to have_content 'Some random note' + expect(page).to have_content 'Another note' + end + + it 'returns an error if the params are malformed' do + # Note that this is missing one `-` + add_note("/clone -with_notes #{target_project.full_path}") + + wait_for_requests + + expect(page).to have_content 'Failed to clone this issue: wrong parameters.' + expect(issue.reload).to be_open + end + end + end + + context 'when the project is valid but the user not authorized' do + let(:project_unauthorized) { create(:project, :public) } + + it 'does not clone the issue' do + add_note("/clone #{project_unauthorized.full_path}") + + wait_for_requests + + expect(page).to have_content "Cloned this issue to #{project_unauthorized.full_path}." + expect(issue.reload).to be_open + + visit project_issue_path(target_project, issue) + + expect(page).not_to have_content 'Issues 1' + end + end + + context 'when the project is invalid' do + it 'does not clone the issue' do + add_note("/clone not/valid") + + wait_for_requests + + expect(page).to have_content "Failed to clone this issue because target project doesn't exist." + expect(issue.reload).to be_open + end + end + + context 'when the user issues multiple commands' do + let(:milestone) { create(:milestone, title: '1.0', project: project) } + let(:bug) { create(:label, project: project, title: 'bug') } + let(:wontfix) { create(:label, project: project, title: 'wontfix') } + + let!(:target_milestone) { create(:milestone, title: '1.0', project: target_project) } + + before do + target_project.add_maintainer(user) + end + + shared_examples 'applies the commands to issues in both projects, target and source' do + it "applies quick actions" do + expect(page).to have_content "Cloned this issue to #{target_project.full_path}." + expect(issue.reload).to be_open + + visit project_issue_path(target_project, issue) + + expect(page).to have_content 'bug' + expect(page).to have_content 'wontfix' + expect(page).to have_content '1.0' + + visit project_issue_path(project, issue) + expect(page).to have_content 'bug' + expect(page).to have_content 'wontfix' + expect(page).to have_content '1.0' + end + end + + context 'applies multiple commands with clone command in the end' do + before do + add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/clone #{target_project.full_path}") + end + + it_behaves_like 'applies the commands to issues in both projects, target and source' + end + + context 'applies multiple commands with clone command in the begining' do + before do + add_note("/clone #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"") + end + + it_behaves_like 'applies the commands to issues in both projects, target and source' + end + end + + context 'when editing comments' do + let(:target_project) { create(:project, :public) } + + before do + target_project.add_maintainer(user) + + sign_in(user) + visit project_issue_path(project, issue) + wait_for_all_requests + end + + it 'clones the issue after quickcommand note was updated' do + # misspelled quick action + add_note("test note.\n/cloe #{target_project.full_path}") + + expect(issue.reload).not_to be_closed + + edit_note("/cloe #{target_project.full_path}", "test note.\n/clone #{target_project.full_path}") + wait_for_all_requests + + expect(page).to have_content 'test note.' + expect(issue.reload).to be_open + + visit project_issue_path(target_project, issue) + wait_for_all_requests + + expect(page).to have_content 'Issues 1' + end + + it 'deletes the note if it was updated to just contain a command' do + # missspelled quick action + add_note("test note.\n/cloe #{target_project.full_path}") + + expect(page).not_to have_content 'Commands applied' + + edit_note("/cloe #{target_project.full_path}", "/clone #{target_project.full_path}") + wait_for_all_requests + + expect(page).not_to have_content "/clone #{target_project.full_path}" + expect(issue.reload).to be_open + + visit project_issue_path(target_project, issue) + wait_for_all_requests + + expect(page).to have_content 'Issues 1' + end + end + end +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 index c56290a0aa9..49b6fc13900 100644 --- a/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/conan_packages_shared_examples.rb @@ -629,6 +629,7 @@ RSpec.shared_examples 'workhorse recipe file upload endpoint' do 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' + it_behaves_like 'creates build_info when there is a job' end RSpec.shared_examples 'workhorse package file upload endpoint' do @@ -649,6 +650,7 @@ RSpec.shared_examples 'workhorse package file upload endpoint' do it_behaves_like 'rejects invalid recipe' it_behaves_like 'rejects invalid file_name', 'conaninfo.txttest' it_behaves_like 'uploads a package file' + it_behaves_like 'creates build_info when there is a job' context 'tracking the conan_package.tgz upload' do let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY } @@ -657,6 +659,20 @@ RSpec.shared_examples 'workhorse package file upload endpoint' do end end +RSpec.shared_examples 'creates build_info when there is a job' do + context 'with job token' do + let(:jwt) { build_jwt_from_job(job) } + + it 'creates a build_info record' do + expect { subject }.to change { Packages::BuildInfo.count }.by(1) + end + + it 'creates a package_file_build_info record' do + expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1) + end + end +end + RSpec.shared_examples 'uploads a package file' do context 'file size above maximum limit' do before do diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb index 5145880ef9a..54f4ba7ff73 100644 --- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb @@ -47,18 +47,12 @@ RSpec.shared_examples 'group and project boards query' do describe 'sorting and pagination' do let(:data_path) { [board_parent_type, :boards] } - def pagination_query(params, page_info) - graphql_query_for( - board_parent_type, - { 'fullPath' => board_parent.full_path }, - query_graphql_field('boards', params, "#{page_info} edges { node { id } }") + def pagination_query(params) + graphql_query_for(board_parent_type, { full_path: board_parent.full_path }, + query_nodes(:boards, :id, include_pagination_info: true, args: params) ) end - def pagination_results_data(data) - data.map { |board| board.dig('node', 'id') } - end - context 'when using default sorting' do let!(:board_B) { create(:board, resource_parent: board_parent, name: 'B') } let!(:board_C) { create(:board, resource_parent: board_parent, name: 'C') } @@ -72,9 +66,9 @@ RSpec.shared_examples 'group and project boards query' do let(:first_param) { 2 } let(:expected_results) do if board_parent.multiple_issue_boards_available? - boards.map { |board| board.to_global_id.to_s } + boards.map { |board| global_id_of(board) } else - [boards.first.to_global_id.to_s] + [global_id_of(boards.first)] end end end diff --git a/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb new file mode 100644 index 00000000000..f808d12baf4 --- /dev/null +++ b/spec/support/shared_examples/requests/api/nuget_endpoints_shared_examples.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handling nuget service requests' do + subject { get api(url) } + + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + context 'personal token' do + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget service index request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget service index request' | :success + 'PUBLIC' | :developer | true | false | 'process nuget service index request' | :success + 'PUBLIC' | :guest | true | false | 'process nuget service index request' | :success + 'PUBLIC' | :developer | false | true | 'process nuget service index request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget service index request' | :success + 'PUBLIC' | :developer | false | false | 'process nuget service index request' | :success + 'PUBLIC' | :guest | false | false | 'process nuget service index request' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget service index request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget service index request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + context 'with job token' do + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget service index request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget service index request' | :success + 'PUBLIC' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :developer | false | true | 'process nuget service index request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget service index request' | :success + 'PUBLIC' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'process nuget service index request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget service index request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:job) { user_token ? create(:ci_build, project: project, user: user, status: :running) : double(token: 'wrong') } + let(:headers) { user_role == :anonymous ? {} : job_basic_auth_header(job) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' +end + +RSpec.shared_examples 'handling nuget metadata requests with package name' do + include_context 'with expected presenters dependency groups' + + let_it_be(:package_name) { 'Dummy.Package' } + let_it_be(:packages) { create_list(:nuget_package, 5, :with_metadatum, name: package_name, project: project) } + let_it_be(:tags) { packages.each { |pkg| create(:packages_tag, package: pkg, name: 'test') } } + + subject { get api(url) } + + before do + packages.each { |pkg| create_dependencies_for(pkg) } + end + + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | true | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :developer | true | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | true | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :developer | false | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | false | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :developer | false | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | false | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget metadata request at package name level' | :success + 'PRIVATE' | :developer | true | true | 'process nuget metadata request at package name level' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end +end + +RSpec.shared_examples 'handling nuget metadata requests with package name and package version' do + include_context 'with expected presenters dependency groups' + + let_it_be(:package_name) { 'Dummy.Package' } + let_it_be(:package) { create(:nuget_package, :with_metadatum, name: 'Dummy.Package', project: project) } + let_it_be(:tag) { create(:packages_tag, package: package, name: 'test') } + + subject { get api(url) } + + before do + create_dependencies_for(package) + end + + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | true | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :developer | true | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | true | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :developer | false | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | false | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :developer | false | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | false | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget metadata request at package name and package version level' | :success + 'PRIVATE' | :developer | true | true | 'process nuget metadata request at package name and package version level' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + context 'with invalid package name' do + let_it_be(:package_name) { 'Unkown' } + + it_behaves_like 'rejects nuget packages access', :developer, :not_found + end +end + +RSpec.shared_examples 'handling nuget search requests' do + let_it_be(:package_a) { create(:nuget_package, :with_metadatum, name: 'Dummy.PackageA', project: project) } + let_it_be(:tag) { create(:packages_tag, package: package_a, name: 'test') } + let_it_be(:packages_b) { create_list(:nuget_package, 5, name: 'Dummy.PackageB', project: project) } + let_it_be(:packages_c) { create_list(:nuget_package, 5, name: 'Dummy.PackageC', project: project) } + let_it_be(:package_d) { create(:nuget_package, name: 'Dummy.PackageD', version: '5.0.5-alpha', project: project) } + let_it_be(:package_e) { create(:nuget_package, name: 'Foo.BarE', project: project) } + let(:search_term) { 'uMmy' } + let(:take) { 26 } + let(:skip) { 0 } + let(:include_prereleases) { true } + let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } } + + subject { get api(url) } + + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget search request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget search request' | :success + 'PUBLIC' | :developer | true | false | 'process nuget search request' | :success + 'PUBLIC' | :guest | true | false | 'process nuget search request' | :success + 'PUBLIC' | :developer | false | true | 'process nuget search request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget search request' | :success + 'PUBLIC' | :developer | false | false | 'process nuget search request' | :success + 'PUBLIC' | :guest | false | false | 'process nuget search request' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget search request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget search request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' +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 58e99776fd9..dc6ac5f0371 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 @@ -12,7 +12,7 @@ RSpec.shared_examples 'rejects nuget packages access' do |user_type, status, add it 'has the correct response header' do subject - expect(response.headers['Www-Authenticate: Basic realm']).to eq 'GitLab Packages Registry' + expect(response.headers['WWW-Authenticate']).to eq 'Basic realm="GitLab Packages Registry"' end end end @@ -26,7 +26,7 @@ RSpec.shared_examples 'process nuget service index request' do |user_type, statu it_behaves_like 'returning response status', status - it_behaves_like 'a package tracking event', described_class.name, 'cli_metadata' + it_behaves_like 'a package tracking event', 'API::NugetPackages', 'cli_metadata' it 'returns a valid json response' do subject @@ -169,7 +169,7 @@ RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member = context 'with correct params' do it_behaves_like 'package workhorse uploads' it_behaves_like 'creates nuget package files' - it_behaves_like 'a package tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', 'API::NugetPackages', 'push_package' end end @@ -286,7 +286,7 @@ RSpec.shared_examples 'process nuget download content request' do |user_type, st it_behaves_like 'returning response status', status - it_behaves_like 'a package tracking event', described_class.name, 'pull_package' + it_behaves_like 'a package tracking event', 'API::NugetPackages', 'pull_package' it 'returns a valid package archive' do subject @@ -336,7 +336,7 @@ RSpec.shared_examples 'process nuget search request' do |user_type, status, add_ it_behaves_like 'returns a valid json search response', status, 4, [1, 5, 5, 1] - it_behaves_like 'a package tracking event', described_class.name, 'search_package' + it_behaves_like 'a package tracking event', 'API::NugetPackages', 'search_package' context 'with skip set to 2' do let(:skip) { 2 } diff --git a/spec/support/shared_examples/requests/graphql_shared_examples.rb b/spec/support/shared_examples/requests/graphql_shared_examples.rb index 0045fe14501..a66bc7112fe 100644 --- a/spec/support/shared_examples/requests/graphql_shared_examples.rb +++ b/spec/support/shared_examples/requests/graphql_shared_examples.rb @@ -9,3 +9,8 @@ RSpec.shared_examples 'a working graphql query' do expect(json_response.keys).to include('data') end end + +RSpec.shared_examples 'a mutation on an unauthorized resource' do + it_behaves_like 'a mutation that returns top-level errors', + errors: [::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] +end diff --git a/spec/support/shared_examples/requests/lfs_http_shared_examples.rb b/spec/support/shared_examples/requests/lfs_http_shared_examples.rb index 4ae77179527..294ceffd77b 100644 --- a/spec/support/shared_examples/requests/lfs_http_shared_examples.rb +++ b/spec/support/shared_examples/requests/lfs_http_shared_examples.rb @@ -65,12 +65,19 @@ end RSpec.shared_examples 'LFS http requests' do include LfsHttpHelpers + let(:lfs_enabled) { true } let(:authorize_guest) {} let(:authorize_download) {} let(:authorize_upload) {} let(:lfs_object) { create(:lfs_object, :with_file) } let(:sample_oid) { lfs_object.oid } + let(:sample_size) { lfs_object.size } + let(:sample_object) { { 'oid' => sample_oid, 'size' => sample_size } } + let(:non_existing_object_oid) { '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897' } + let(:non_existing_object_size) { 1575078 } + let(:non_existing_object) { { 'oid' => non_existing_object_oid, 'size' => non_existing_object_size } } + let(:multiple_objects) { [sample_object, non_existing_object] } let(:authorization) { authorize_user } let(:headers) do @@ -89,13 +96,11 @@ RSpec.shared_examples 'LFS http requests' do end before do - stub_lfs_setting(enabled: true) + stub_lfs_setting(enabled: lfs_enabled) end context 'when LFS is disabled globally' do - before do - stub_lfs_setting(enabled: false) - end + let(:lfs_enabled) { false } describe 'download request' do before do diff --git a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb index d4ee68309ff..5d300d38e4a 100644 --- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb +++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb @@ -23,6 +23,11 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds end + after do + stub_env('GITLAB_THROTTLE_USER_ALLOWLIST', nil) + Gitlab::RackAttack.configure_user_allowlist + end + context 'when the throttle is enabled' do before do settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true @@ -30,6 +35,8 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do end it 'rejects requests over the rate limit' do + expect(Gitlab::Instrumentation::Throttle).not_to receive(:safelist=) + # At first, allow requests under the rate limit. requests_per_period.times do make_request(request_args) @@ -40,6 +47,18 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do expect_rejection { make_request(request_args) } end + it 'does not reject requests if the user is in the allowlist' do + stub_env('GITLAB_THROTTLE_USER_ALLOWLIST', user.id.to_s) + Gitlab::RackAttack.configure_user_allowlist + + expect(Gitlab::Instrumentation::Throttle).to receive(:safelist=).with('throttle_user_allowlist').at_least(:once) + + (requests_per_period + 1).times do + make_request(request_args) + expect(response).not_to have_gitlab_http_status(:too_many_requests) + end + end + it 'allows requests after throttling and then waiting for the next period' do requests_per_period.times do make_request(request_args) @@ -110,6 +129,14 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do expect { make_request(request_args) }.not_to exceed_query_limit(control_count) end end + + it_behaves_like 'tracking when dry-run mode is set' do + let(:throttle_name) { throttle_types[throttle_setting_prefix] } + + def do_request + make_request(request_args) + end + end end context 'when the throttle is disabled' do @@ -159,6 +186,11 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do settings_to_set[:"#{throttle_setting_prefix}_period_in_seconds"] = period_in_seconds end + after do + stub_env('GITLAB_THROTTLE_USER_ALLOWLIST', nil) + Gitlab::RackAttack.configure_user_allowlist + end + context 'when the throttle is enabled' do before do settings_to_set[:"#{throttle_setting_prefix}_enabled"] = true @@ -166,6 +198,8 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do end it 'rejects requests over the rate limit' do + expect(Gitlab::Instrumentation::Throttle).not_to receive(:safelist=) + # At first, allow requests under the rate limit. requests_per_period.times do request_authenticated_web_url @@ -176,6 +210,18 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do expect_rejection { request_authenticated_web_url } end + it 'does not reject requests if the user is in the allowlist' do + stub_env('GITLAB_THROTTLE_USER_ALLOWLIST', user.id.to_s) + Gitlab::RackAttack.configure_user_allowlist + + expect(Gitlab::Instrumentation::Throttle).to receive(:safelist=).with('throttle_user_allowlist').at_least(:once) + + (requests_per_period + 1).times do + request_authenticated_web_url + expect(response).not_to have_gitlab_http_status(:too_many_requests) + end + end + it 'allows requests after throttling and then waiting for the next period' do requests_per_period.times do request_authenticated_web_url @@ -245,6 +291,14 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do expect(Gitlab::AuthLogger).to receive(:error).with(arguments).once expect { request_authenticated_web_url }.not_to exceed_query_limit(control_count) end + + it_behaves_like 'tracking when dry-run mode is set' do + let(:throttle_name) { throttle_types[throttle_setting_prefix] } + + def do_request + request_authenticated_web_url + end + end end context 'when the throttle is disabled' do @@ -269,3 +323,63 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do end end end + +# Requires: +# - #do_request - This needs to be a method so the result isn't memoized +# - throttle_name +RSpec.shared_examples 'tracking when dry-run mode is set' do + let(:dry_run_config) { '*' } + + # we can't use `around` here, because stub_env isn't supported outside of the + # example itself + before do + stub_env('GITLAB_THROTTLE_DRY_RUN', dry_run_config) + reset_rack_attack + end + + after do + stub_env('GITLAB_THROTTLE_DRY_RUN', '') + reset_rack_attack + end + + def reset_rack_attack + Rack::Attack.reset! + Rack::Attack.clear_configuration + Gitlab::RackAttack.configure(Rack::Attack) + end + + it 'does not throttle the requests when `*` is configured' do + (1 + requests_per_period).times do + do_request + expect(response).not_to have_gitlab_http_status(:too_many_requests) + end + end + + it 'logs RackAttack info into structured logs' do + arguments = a_hash_including({ + message: 'Rack_Attack', + env: :track, + remote_ip: '127.0.0.1', + matched: throttle_name + }) + + expect(Gitlab::AuthLogger).to receive(:error).with(arguments) + + (1 + requests_per_period).times do + do_request + end + end + + context 'when configured with the the throttled name in a list' do + let(:dry_run_config) do + "throttle_list, #{throttle_name}, other_throttle" + end + + it 'does not throttle' do + (1 + requests_per_period).times do + do_request + expect(response).not_to have_gitlab_http_status(:too_many_requests) + end + end + end +end diff --git a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb index db11b1fe07d..ff87fc5d8df 100644 --- a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb +++ b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb @@ -20,6 +20,18 @@ RSpec.shared_examples 'not accessible to non-admin users' do expect(response).to have_gitlab_http_status(:not_found) end end + + context 'with authenticated admin user without admin mode' do + before do + login_as(create(:admin)) + end + + it 'redirects to enable admin mode' do + subject + + expect(response).to redirect_to(new_admin_session_path) + end + end end # Requires subject and worker_class and status_api to be defined diff --git a/spec/support/shared_examples/requests/uploads_auhorize_shared_examples.rb b/spec/support/shared_examples/requests/uploads_auhorize_shared_examples.rb new file mode 100644 index 00000000000..9cef5cfc25e --- /dev/null +++ b/spec/support/shared_examples/requests/uploads_auhorize_shared_examples.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'handle uploads authorize request' do + before do + login_as(user) + end + + describe 'POST authorize' do + it 'authorizes workhorse header' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(uploader_class.workhorse_local_upload_path) + end + + it 'rejects requests that bypassed gitlab-workhorse' do + workhorse_headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + expect { subject }.to raise_error(JWT::DecodeError) + end + + context 'when using remote storage' do + context 'when direct upload is enabled' do + before do + stub_uploads_object_storage(uploader_class, enabled: true, direct_upload: true) + end + + it 'responds with status 200, location of file remote store and object details' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type.to_s).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']).to have_key('MultipartUpload') + expect(json_response['MaximumSize']).to eq(maximum_size) + end + end + + context 'when direct upload is disabled' do + before do + stub_uploads_object_storage(uploader_class, 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_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(uploader_class.workhorse_local_upload_path) + expect(json_response['RemoteObject']).to be_nil + expect(json_response['MaximumSize']).to eq(maximum_size) + end + end + end + end +end diff --git a/spec/support/shared_examples/routing/git_http_routing_shared_examples.rb b/spec/support/shared_examples/routing/git_http_routing_shared_examples.rb new file mode 100644 index 00000000000..b0e1e942d81 --- /dev/null +++ b/spec/support/shared_examples/routing/git_http_routing_shared_examples.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'git repository routes' do + let(:params) { { repository_path: path.delete_prefix('/') } } + let(:container_path) { path.delete_suffix('.git') } + + it 'routes Git endpoints' do + expect(get("#{path}/info/refs")).to route_to('repositories/git_http#info_refs', **params) + expect(post("#{path}/git-upload-pack")).to route_to('repositories/git_http#git_upload_pack', **params) + expect(post("#{path}/git-receive-pack")).to route_to('repositories/git_http#git_receive_pack', **params) + end + + context 'requests without .git format' do + it 'redirects requests to /info/refs', type: :request do + expect(get("#{container_path}/info/refs")).to redirect_to("#{container_path}.git/info/refs") + expect(get("#{container_path}/info/refs?service=git-upload-pack")).to redirect_to("#{container_path}.git/info/refs?service=git-upload-pack") + expect(get("#{container_path}/info/refs?service=git-receive-pack")).to redirect_to("#{container_path}.git/info/refs?service=git-receive-pack") + end + + it 'does not redirect other requests' do + expect(post("#{container_path}/git-upload-pack")).not_to be_routable + end + end + + it 'routes LFS endpoints' do + oid = generate(:oid) + + expect(post("#{path}/info/lfs/objects/batch")).to route_to('repositories/lfs_api#batch', **params) + expect(post("#{path}/info/lfs/objects")).to route_to('repositories/lfs_api#deprecated', **params) + expect(get("#{path}/info/lfs/objects/#{oid}")).to route_to('repositories/lfs_api#deprecated', oid: oid, **params) + + expect(post("#{path}/info/lfs/locks/123/unlock")).to route_to('repositories/lfs_locks_api#unlock', id: '123', **params) + expect(post("#{path}/info/lfs/locks/verify")).to route_to('repositories/lfs_locks_api#verify', **params) + + expect(get("#{path}/gitlab-lfs/objects/#{oid}")).to route_to('repositories/lfs_storage#download', oid: oid, **params) + expect(put("#{path}/gitlab-lfs/objects/#{oid}/456/authorize")).to route_to('repositories/lfs_storage#upload_authorize', oid: oid, size: '456', **params) + expect(put("#{path}/gitlab-lfs/objects/#{oid}/456")).to route_to('repositories/lfs_storage#upload_finalize', oid: oid, size: '456', **params) + + expect(put("#{path}/gitlab-lfs/objects/foo")).not_to be_routable + expect(put("#{path}/gitlab-lfs/objects/#{oid}/foo")).not_to be_routable + expect(put("#{path}/gitlab-lfs/objects/#{oid}/foo/authorize")).not_to be_routable + 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 003705ca21c..d9f28a97a0f 100644 --- a/spec/support/shared_examples/services/alert_management_shared_examples.rb +++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb @@ -16,6 +16,38 @@ RSpec.shared_examples 'creates an alert management alert' do end end +# This shared_example requires the following variables: +# - last_alert_attributes, last created alert +# - project, project that alert created +# - payload_raw, hash representation of payload +# - environment, project's environment +# - fingerprint, fingerprint hash +RSpec.shared_examples 'assigns the alert properties' do + it 'ensures that created alert has all data properly assigned' do + subject + + expect(last_alert_attributes).to match( + project_id: project.id, + title: payload_raw.fetch(:title), + started_at: Time.zone.parse(payload_raw.fetch(:start_time)), + severity: payload_raw.fetch(:severity), + status: AlertManagement::Alert.status_value(:triggered), + events: 1, + domain: domain, + hosts: payload_raw.fetch(:hosts), + payload: payload_raw.with_indifferent_access, + issue_id: nil, + description: payload_raw.fetch(:description), + monitoring_tool: payload_raw.fetch(:monitoring_tool), + service: payload_raw.fetch(:service), + fingerprint: Digest::SHA1.hexdigest(fingerprint), + environment_id: environment.id, + ended_at: nil, + prometheus_alert_id: nil + ) + end +end + RSpec.shared_examples 'does not an create alert management alert' do it 'does not create alert' do expect { subject }.not_to change(AlertManagement::Alert, :count) diff --git a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb new file mode 100644 index 00000000000..ba176b616c3 --- /dev/null +++ b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb @@ -0,0 +1,1006 @@ +# frozen_string_literal: true + +RSpec.shared_context 'container registry auth service context' do + let(:current_project) { nil } + let(:current_user) { nil } + let(:current_params) { {} } + let(:rsa_key) { OpenSSL::PKey::RSA.generate(512) } + let(:payload) { JWT.decode(subject[:token], rsa_key, true, { algorithm: 'RS256' }).first } + + let(:authentication_abilities) do + [:read_container_image, :create_container_image, :admin_container_image] + end + + let(:log_data) { { message: 'Denied container registry permissions' } } + + subject do + described_class.new(current_project, current_user, current_params) + .execute(authentication_abilities: authentication_abilities) + end + + before do + allow(Gitlab.config.registry).to receive_messages(enabled: true, issuer: 'rspec', key: nil) + allow_next_instance_of(JSONWebToken::RSAToken) do |instance| + allow(instance).to receive(:key).and_return(rsa_key) + end + end +end + +RSpec.shared_examples 'an authenticated' do + it { is_expected.to include(:token) } + it { expect(payload).to include('access') } +end + +RSpec.shared_examples 'a valid token' do + it { is_expected.to include(:token) } + it { expect(payload).to include('access') } + + context 'a expirable' do + let(:expires_at) { Time.zone.at(payload['exp']) } + let(:expire_delay) { 10 } + + context 'for default configuration' do + it { expect(expires_at).not_to be_within(2.seconds).of(Time.current + expire_delay.minutes) } + end + + context 'for changed configuration' do + before do + stub_application_setting(container_registry_token_expire_delay: expire_delay) + end + + it { expect(expires_at).to be_within(2.seconds).of(Time.current + expire_delay.minutes) } + end + end +end + +RSpec.shared_examples 'a browsable' do + let(:access) do + [{ 'type' => 'registry', + 'name' => 'catalog', + 'actions' => ['*'] }] + end + + it_behaves_like 'a valid token' + it_behaves_like 'not a container repository factory' + + it 'has the correct scope' do + expect(payload).to include('access' => access) + end +end + +RSpec.shared_examples 'an accessible' do + let(:access) do + [{ 'type' => 'repository', + 'name' => project.full_path, + 'actions' => actions }] + end + + it_behaves_like 'a valid token' + + it 'has the correct scope' do + expect(payload).to include('access' => access) + end +end + +RSpec.shared_examples 'an inaccessible' do + it_behaves_like 'a valid token' + it { expect(payload).to include('access' => []) } +end + +RSpec.shared_examples 'a deletable' do + it_behaves_like 'an accessible' do + let(:actions) { ['*'] } + end +end + +RSpec.shared_examples 'a deletable since registry 2.7' do + it_behaves_like 'an accessible' do + let(:actions) { ['delete'] } + end +end + +RSpec.shared_examples 'a pullable' do + it_behaves_like 'an accessible' do + let(:actions) { ['pull'] } + end +end + +RSpec.shared_examples 'a pushable' do + it_behaves_like 'an accessible' do + let(:actions) { ['push'] } + end +end + +RSpec.shared_examples 'a pullable and pushable' do + it_behaves_like 'an accessible' do + let(:actions) { %w(pull push) } + end +end + +RSpec.shared_examples 'a forbidden' do + it { is_expected.to include(http_status: 403) } + it { is_expected.not_to include(:token) } +end + +RSpec.shared_examples 'container repository factory' do + it 'creates a new container repository resource' do + expect { subject } + .to change { project.container_repositories.count }.by(1) + end +end + +RSpec.shared_examples 'not a container repository factory' do + it 'does not create a new container repository resource' do + expect { subject }.not_to change { ContainerRepository.count } + end +end + +RSpec.shared_examples 'logs an auth warning' do |requested_actions| + let(:expected) do + { + scope_type: 'repository', + requested_project_path: project.full_path, + requested_actions: requested_actions, + authorized_actions: [], + user_id: current_user.id, + username: current_user.username + } + end + + it do + expect(Gitlab::AuthLogger).to receive(:warn).with(expected.merge!(log_data)) + + subject + end +end + +RSpec.shared_examples 'a container registry auth service' do + include_context 'container registry auth service context' + + describe '#full_access_token' do + let_it_be(:project) { create(:project) } + let(:token) { described_class.full_access_token(project.full_path) } + + subject { { token: token } } + + it_behaves_like 'an accessible' do + let(:actions) { ['*'] } + end + + it_behaves_like 'not a container repository factory' + end + + describe '#pull_access_token' do + let_it_be(:project) { create(:project) } + let(:token) { described_class.pull_access_token(project.full_path) } + + subject { { token: token } } + + it_behaves_like 'an accessible' do + let(:actions) { ['pull'] } + end + + it_behaves_like 'not a container repository factory' + end + + context 'user authorization' do + let_it_be(:current_user) { create(:user) } + + context 'for registry catalog' do + let(:current_params) do + { scopes: ["registry:catalog:*"] } + end + + context 'disallow browsing for users without GitLab admin rights' do + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + + context 'for private project' do + let_it_be(:project) { create(:project) } + + context 'allow to use scope-less authentication' do + it_behaves_like 'a valid token' + end + + context 'allow developer to push images' do + before_all do + project.add_developer(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'a pushable' + it_behaves_like 'container repository factory' + end + + context 'disallow developer to delete images' do + before_all do + project.add_developer(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + + it_behaves_like 'logs an auth warning', ['*'] + end + + context 'disallow developer to delete images since registry 2.7' do + before_all do + project.add_developer(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'allow reporter to pull images' do + before_all do + project.add_reporter(current_user) + end + + context 'when pulling from root level repository' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + end + + context 'disallow reporter to delete images' do + before_all do + project.add_reporter(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow reporter to delete images since registry 2.7' do + before_all do + project.add_reporter(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'return a least of privileges' do + before_all do + project.add_reporter(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push,pull"] } + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + context 'disallow guest to pull or push images' do + before_all do + project.add_guest(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull,push"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow guest to delete images' do + before_all do + project.add_guest(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow guest to delete images since registry 2.7' do + before_all do + project.add_guest(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + + context 'for public project' do + let_it_be(:project) { create(:project, :public) } + + context 'allow anyone to pull images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to push images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to delete images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to delete images since registry 2.7' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'when repository name is invalid' do + let(:current_params) do + { scopes: ['repository:invalid:push'] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + + context 'for internal project' do + let_it_be(:project) { create(:project, :internal) } + + context 'for internal user' do + context 'allow anyone to pull images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to push images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to delete images' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to delete images since registry 2.7' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + + context 'for external user' do + context 'disallow anyone to pull or push images' do + let_it_be(:current_user) { create(:user, external: true) } + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull,push"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to delete images' do + let_it_be(:current_user) { create(:user, external: true) } + let(:current_params) do + { scopes: ["repository:#{project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'disallow anyone to delete images since registry 2.7' do + let_it_be(:current_user) { create(:user, external: true) } + let(:current_params) do + { scopes: ["repository:#{project.full_path}:delete"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + end + end + + context 'delete authorized as maintainer' do + let_it_be(:current_project) { create(:project) } + let_it_be(:current_user) { create(:user) } + + let(:authentication_abilities) do + [:admin_container_image] + end + + before_all do + current_project.add_maintainer(current_user) + end + + it_behaves_like 'a valid token' + + context 'allow to delete images' do + let(:current_params) do + { scopes: ["repository:#{current_project.full_path}:*"] } + end + + it_behaves_like 'a deletable' do + let(:project) { current_project } + end + end + + context 'allow to delete images since registry 2.7' do + let(:current_params) do + { scopes: ["repository:#{current_project.full_path}:delete"] } + end + + it_behaves_like 'a deletable since registry 2.7' do + let(:project) { current_project } + end + end + end + + context 'build authorized as user' do + let_it_be(:current_project) { create(:project) } + let_it_be(:current_user) { create(:user) } + + let(:authentication_abilities) do + [:build_read_container_image, :build_create_container_image, :build_destroy_container_image] + end + + before_all do + current_project.add_developer(current_user) + end + + context 'allow to use offline_token' do + let(:current_params) do + { offline_token: true } + end + + it_behaves_like 'an authenticated' + end + + it_behaves_like 'a valid token' + + context 'allow to pull and push images' do + let(:current_params) do + { scopes: ["repository:#{current_project.full_path}:pull,push"] } + end + + it_behaves_like 'a pullable and pushable' do + let(:project) { current_project } + end + + it_behaves_like 'container repository factory' do + let(:project) { current_project } + end + end + + context 'allow to delete images since registry 2.7' do + let(:current_params) do + { scopes: ["repository:#{current_project.full_path}:delete"] } + end + + it_behaves_like 'a deletable since registry 2.7' do + let(:project) { current_project } + end + end + + context 'disallow to delete images' do + let(:current_params) do + { scopes: ["repository:#{current_project.full_path}:*"] } + end + + it_behaves_like 'an inaccessible' do + let(:project) { current_project } + end + end + + context 'for other projects' do + context 'when pulling' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + context 'allow for public' do + let_it_be(:project) { create(:project, :public) } + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + shared_examples 'pullable for being team member' do + context 'when you are not member' do + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'when you are member' do + before_all do + project.add_developer(current_user) + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + context 'when you are owner' do + let_it_be(:project) { create(:project, namespace: current_user.namespace) } + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + end + + context 'for private' do + let_it_be(:project) { create(:project, :private) } + + it_behaves_like 'pullable for being team member' + + context 'when you are admin' do + let_it_be(:current_user) { create(:admin) } + + context 'when you are not member' do + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'when you are member' do + before_all do + project.add_developer(current_user) + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + context 'when you are owner' do + let_it_be(:project) { create(:project, namespace: current_user.namespace) } + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + end + end + end + + context 'when pushing' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + context 'disallow for all' do + context 'when you are member' do + let_it_be(:project) { create(:project, :public) } + + before_all do + project.add_developer(current_user) + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + + context 'when you are owner' do + let_it_be(:project) { create(:project, :public, namespace: current_user.namespace) } + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + end + end + + context 'for project without container registry' do + let_it_be(:project) { create(:project, :public, container_registry_enabled: false) } + + before do + project.update!(container_registry_enabled: false) + end + + context 'disallow when pulling' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + + context 'for project that disables repository' do + let_it_be(:project) { create(:project, :public, :repository_disabled) } + + context 'disallow when pulling' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + it_behaves_like 'an inaccessible' + it_behaves_like 'not a container repository factory' + end + end + end + + context 'registry catalog browsing authorized as admin' do + let_it_be(:current_user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :public) } + + let(:current_params) do + { scopes: ["registry:catalog:*"] } + end + + it_behaves_like 'a browsable' + end + + context 'support for multiple scopes' do + let_it_be(:internal_project) { create(:project, :internal) } + let_it_be(:private_project) { create(:project, :private) } + + let(:current_params) do + { + scopes: [ + "repository:#{internal_project.full_path}:pull", + "repository:#{private_project.full_path}:pull" + ] + } + end + + context 'user has access to all projects' do + let_it_be(:current_user) { create(:user, :admin) } + + before do + enable_admin_mode!(current_user) + end + + it_behaves_like 'a browsable' do + let(:access) do + [ + { 'type' => 'repository', + 'name' => internal_project.full_path, + 'actions' => ['pull'] }, + { 'type' => 'repository', + 'name' => private_project.full_path, + 'actions' => ['pull'] } + ] + end + end + end + + context 'user only has access to internal project' do + let_it_be(:current_user) { create(:user) } + + it_behaves_like 'a browsable' do + let(:access) do + [ + { 'type' => 'repository', + 'name' => internal_project.full_path, + 'actions' => ['pull'] } + ] + end + end + end + + context 'anonymous access is rejected' do + let(:current_user) { nil } + + it_behaves_like 'a forbidden' + end + end + + context 'unauthorized' do + context 'disallow to use scope-less authentication' do + it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' + end + + context 'for invalid scope' do + let(:current_params) do + { scopes: ['invalid:aa:bb'] } + end + + it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' + end + + context 'for private project' do + let_it_be(:project) { create(:project, :private) } + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + it_behaves_like 'a forbidden' + end + + context 'for public project' do + let_it_be(:project) { create(:project, :public) } + + context 'when pulling and pushing' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull,push"] } + end + + it_behaves_like 'a pullable' + it_behaves_like 'not a container repository factory' + end + + context 'when pushing' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' + end + end + + context 'for registry catalog' do + let(:current_params) do + { scopes: ["registry:catalog:*"] } + end + + it_behaves_like 'a forbidden' + it_behaves_like 'not a container repository factory' + end + end + + context 'for deploy tokens' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:pull"] } + end + + context 'when deploy token has read and write registry as scopes' do + let(:current_user) { create(:deploy_token, write_registry: true, projects: [project]) } + + shared_examples 'able to login' do + context 'registry provides read_container_image authentication_abilities' do + let(:current_params) { {} } + let(:authentication_abilities) { [:read_container_image] } + + it_behaves_like 'an authenticated' + end + end + + context 'for public project' do + let_it_be(:project) { create(:project, :public) } + + context 'when pulling' do + it_behaves_like 'a pullable' + end + + context 'when pushing' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'a pushable' + end + + it_behaves_like 'able to login' + end + + context 'for internal project' do + let_it_be(:project) { create(:project, :internal) } + + context 'when pulling' do + it_behaves_like 'a pullable' + end + + context 'when pushing' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'a pushable' + end + + it_behaves_like 'able to login' + end + + context 'for private project' do + let_it_be(:project) { create(:project, :private) } + + context 'when pulling' do + it_behaves_like 'a pullable' + end + + context 'when pushing' do + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'a pushable' + end + + it_behaves_like 'able to login' + end + end + + context 'when deploy token does not have read_registry scope' do + let(:current_user) { create(:deploy_token, projects: [project], read_registry: false) } + + shared_examples 'unable to login' do + context 'registry provides no container authentication_abilities' do + let(:current_params) { {} } + let(:authentication_abilities) { [] } + + it_behaves_like 'a forbidden' + end + + context 'registry provides inapplicable container authentication_abilities' do + let(:current_params) { {} } + let(:authentication_abilities) { [:download_code] } + + it_behaves_like 'a forbidden' + end + end + + context 'for public project' do + let_it_be(:project) { create(:project, :public) } + + context 'when pulling' do + it_behaves_like 'a pullable' + end + + it_behaves_like 'unable to login' + end + + context 'for internal project' do + let_it_be(:project) { create(:project, :internal) } + + context 'when pulling' do + it_behaves_like 'an inaccessible' + end + + it_behaves_like 'unable to login' + end + + context 'for private project' do + let_it_be(:project) { create(:project, :internal) } + + context 'when pulling' do + it_behaves_like 'an inaccessible' + end + + context 'when logging in' do + let(:current_params) { {} } + let(:authentication_abilities) { [] } + + it_behaves_like 'a forbidden' + end + + it_behaves_like 'unable to login' + end + end + + context 'when deploy token is not related to the project' do + let_it_be(:current_user) { create(:deploy_token, read_registry: false) } + + context 'for public project' do + let_it_be(:project) { create(:project, :public) } + + context 'when pulling' do + it_behaves_like 'a pullable' + end + end + + context 'for internal project' do + let_it_be(:project) { create(:project, :internal) } + + context 'when pulling' do + it_behaves_like 'an inaccessible' + end + end + + context 'for private project' do + let_it_be(:project) { create(:project, :internal) } + + context 'when pulling' do + it_behaves_like 'an inaccessible' + end + end + end + + context 'when deploy token has been revoked' do + let(:current_user) { create(:deploy_token, :revoked, projects: [project]) } + + context 'for public project' do + let_it_be(:project) { create(:project, :public) } + + it_behaves_like 'a pullable' + end + + context 'for internal project' do + let_it_be(:project) { create(:project, :internal) } + + it_behaves_like 'an inaccessible' + end + + context 'for private project' do + let_it_be(:project) { create(:project, :internal) } + + it_behaves_like 'an inaccessible' + end + end + end + + context 'user authorization' do + let_it_be(:current_user) { create(:user) } + + context 'with multiple scopes' do + let_it_be(:project) { create(:project) } + + context 'allow developer to push images' do + before_all do + project.add_developer(current_user) + end + + let(:current_params) do + { scopes: ["repository:#{project.full_path}:push"] } + end + + it_behaves_like 'a pushable' + it_behaves_like 'container repository factory' + end + end + end +end diff --git a/spec/support/shared_examples/services/incident_shared_examples.rb b/spec/support/shared_examples/services/incident_shared_examples.rb index 39c22ac8aa3..9fced12b543 100644 --- a/spec/support/shared_examples/services/incident_shared_examples.rb +++ b/spec/support/shared_examples/services/incident_shared_examples.rb @@ -11,11 +11,13 @@ # # 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 +end + +RSpec.shared_examples 'has incident label' do + let(:label_properties) { attributes_for(:label, :incident) } it 'has exactly one incident label' do expect(issue.labels).to be_one do |label| diff --git a/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb b/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb index 1501a2a0f52..b6c33eac7b4 100644 --- a/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb +++ b/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb @@ -46,7 +46,7 @@ RSpec.shared_examples 'refreshes cache when dashboard_version is changed' do allow(service).to receive(:dashboard_version).and_return('1', '2') end - expect(File).to receive(:read).twice.and_call_original + expect_file_read(Rails.root.join(described_class::DASHBOARD_PATH)).twice.and_call_original service = described_class.new(*service_params) diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb index 7987f2c296b..70d29b4bc99 100644 --- a/spec/support/shared_examples/services/packages_shared_examples.rb +++ b/spec/support/shared_examples/services/packages_shared_examples.rb @@ -14,6 +14,24 @@ RSpec.shared_examples 'assigns build to package' do end end +RSpec.shared_examples 'assigns build to package file' do + context 'with build info' do + let(:job) { create(:ci_build, user: user) } + let(:params) { super().merge(build: job) } + + it 'assigns the pipeline to the package' do + package_file = subject + + expect(package_file.package_file_build_infos).to be_present + expect(package_file.pipelines.first).to eq job.pipeline + end + + it 'creates a new PackageFileBuildInfo record' do + expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1) + end + end +end + RSpec.shared_examples 'assigns the package creator' do it 'assigns the package creator' do subject diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb index 37f44f98cda..1b09b5fe613 100644 --- a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb +++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb @@ -10,6 +10,10 @@ RSpec.shared_examples 'reenqueuer' do expect(subject.lease_timeout).to be_a(ActiveSupport::Duration) end + it 'uses the :none deduplication strategy' do + expect(subject.class.get_deduplicate_strategy).to eq(:none) + end + describe '#perform' do it 'tries to obtain a lease' do expect_to_obtain_exclusive_lease(subject.lease_key) diff --git a/spec/support/shared_examples/workers/project_export_shared_examples.rb b/spec/support/shared_examples/workers/project_export_shared_examples.rb new file mode 100644 index 00000000000..a9bcc3f4f7c --- /dev/null +++ b/spec/support/shared_examples/workers/project_export_shared_examples.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'export worker' do + describe '#perform' do + let!(:user) { create(:user) } + let!(:project) { create(:project) } + + before do + allow_next_instance_of(described_class) do |job| + allow(job).to receive(:jid).and_return(SecureRandom.hex(8)) + end + end + + context 'when it succeeds' do + it 'calls the ExportService' do + expect_next_instance_of(::Projects::ImportExport::ExportService) do |service| + expect(service).to receive(:execute) + end + + subject.perform(user.id, project.id, { 'klass' => 'Gitlab::ImportExport::AfterExportStrategies::DownloadNotificationStrategy' }) + end + + context 'export job' do + before do + allow_next_instance_of(::Projects::ImportExport::ExportService) do |service| + allow(service).to receive(:execute) + end + end + + it 'creates an export job record for the project' do + expect { subject.perform(user.id, project.id, {}) }.to change { project.export_jobs.count }.from(0).to(1) + end + + it 'sets the export job status to started' do + expect_next_instance_of(ProjectExportJob) do |job| + expect(job).to receive(:start) + end + + subject.perform(user.id, project.id, {}) + end + + it 'sets the export job status to finished' do + expect_next_instance_of(ProjectExportJob) do |job| + expect(job).to receive(:finish) + end + + subject.perform(user.id, project.id, {}) + end + end + end + + context 'when it fails' do + it 'does not raise an exception when strategy is invalid' do + expect(::Projects::ImportExport::ExportService).not_to receive(:new) + + expect { subject.perform(user.id, project.id, { 'klass' => 'Whatever' }) }.not_to raise_error + end + + it 'does not raise error when project cannot be found' do + expect { subject.perform(user.id, non_existing_record_id, {}) }.not_to raise_error + end + + it 'does not raise error when user cannot be found' do + expect { subject.perform(non_existing_record_id, project.id, {}) }.not_to raise_error + end + end + end + + describe 'sidekiq options' do + it 'disables retry' do + expect(described_class.sidekiq_options['retry']).to eq(false) + end + + it 'disables dead' do + expect(described_class.sidekiq_options['dead']).to eq(false) + end + + it 'sets default status expiration' do + expect(described_class.sidekiq_options['status_expiration']).to eq(StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION) + end + end +end diff --git a/spec/support/snowplow.rb b/spec/support/snowplow.rb index b67fa96fab8..0d6102f1705 100644 --- a/spec/support/snowplow.rb +++ b/spec/support/snowplow.rb @@ -9,12 +9,15 @@ RSpec.configure do |config| # WebMock is set up to allow requests to `localhost` host = 'localhost' + allow_any_instance_of(Gitlab::Tracking::Destinations::ProductAnalytics).to receive(:event) + allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow) .to receive(:emitter) .and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size)) stub_application_setting(snowplow_enabled: true) + allow(SnowplowTracker::SelfDescribingJson).to receive(:new).and_call_original allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking end |