From fc1cb7f6a68a93ca30e8334d071997b2c7831a64 Mon Sep 17 00:00:00 2001
From: Pirate Praveen
<p> & © Æ Ď
+<p> & © Æ Ď
¾ ℋ ⅆ
∲ ≧̸</p>
@@ -7344,11 +7344,11 @@ stripped in this way:
@@ -7356,12 +7356,12 @@ stripped in this way:
-` `
+` `
` `
-<p><code> </code>
+<p><code> </code>
<code> </code></p>
@@ -7832,11 +7832,11 @@ not part of a [left-flanking delimiter run]:
@@ -9790,7 +9790,7 @@ Other [Unicode whitespace] like non-breaking space doesn't work.
-[link](/url "title")
+[link](/url "title")
diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb
index 70535496b1..6f8d34ea38 100644
--- a/lib/api/repositories.rb
+++ b/lib/api/repositories.rb
@@ -203,6 +203,10 @@ module API
render_api_error!("Target project id:#{params[:from_project_id]} is not a fork of project id:#{params[:id]}", 400)
end
+ unless can?(current_user, :read_code, target_project)
+ forbidden!("You don't have access to this fork's parent project")
+ end
+
cache_key = compare_cache_key(current_user, user_project, target_project, declared_params)
cache_action(cache_key, expires_in: 1.minute) do
diff --git a/lib/banzai/filter/asset_proxy_filter.rb b/lib/banzai/filter/asset_proxy_filter.rb
index 4c14ee7299..6371a8f23a 100644
--- a/lib/banzai/filter/asset_proxy_filter.rb
+++ b/lib/banzai/filter/asset_proxy_filter.rb
@@ -6,11 +6,35 @@ module Banzai
# as well as hiding the customer's IP address when requesting images.
# Copies the original img `src` to `data-canonical-src` then replaces the
# `src` with a new url to the proxy server.
- class AssetProxyFilter < HTML::Pipeline::CamoFilter
+ #
+ # Based on https://github.com/gjtorikian/html-pipeline/blob/v2.14.3/lib/html/pipeline/camo_filter.rb
+ class AssetProxyFilter < HTML::Pipeline::Filter
def initialize(text, context = nil, result = nil)
super
end
+ def call
+ return doc unless asset_proxy_enabled?
+
+ doc.search('img').each do |element|
+ original_src = element['src']
+ next unless original_src
+
+ begin
+ uri = URI.parse(original_src)
+ rescue StandardError
+ next
+ end
+
+ next if uri.host.nil? && !original_src.start_with?('///')
+ next if asset_host_allowed?(uri.host)
+
+ element['src'] = asset_proxy_url(original_src)
+ element['data-canonical-src'] = original_src
+ end
+ doc
+ end
+
def validate
needs(:asset_proxy, :asset_proxy_secret_key) if asset_proxy_enabled?
end
@@ -63,6 +87,24 @@ module Banzai
application_settings.try(:asset_proxy_whitelist).presence ||
[Gitlab.config.gitlab.host]
end
+
+ private
+
+ def asset_proxy_enabled?
+ !context[:disable_asset_proxy]
+ end
+
+ def asset_proxy_url(url)
+ "#{context[:asset_proxy]}/#{asset_url_hash(url)}/#{hexencode(url)}"
+ end
+
+ def asset_url_hash(url)
+ OpenSSL::HMAC.hexdigest('sha1', context[:asset_proxy_secret_key], url)
+ end
+
+ def hexencode(str)
+ str.unpack1('H*')
+ end
end
end
end
diff --git a/lib/banzai/filter/inline_observability_filter.rb b/lib/banzai/filter/inline_observability_filter.rb
index 334c04f2b5..50d4aac70c 100644
--- a/lib/banzai/filter/inline_observability_filter.rb
+++ b/lib/banzai/filter/inline_observability_filter.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'uri'
+
module Banzai
module Filter
class InlineObservabilityFilter < ::Banzai::Filter::InlineEmbedsFilter
@@ -15,7 +17,8 @@ module Banzai
doc.document.create_element(
'div',
class: 'js-render-observability',
- 'data-frame-url': url
+ 'data-frame-url': url,
+ 'data-observability-url': Gitlab::Observability.observability_url
)
end
@@ -28,8 +31,15 @@ module Banzai
# obtained from the target link
def element_to_embed(node)
url = node['href']
+ uri = URI.parse(url)
+ observability_uri = URI.parse(Gitlab::Observability.observability_url)
- create_element(url)
+ if uri.scheme == observability_uri.scheme &&
+ uri.port == observability_uri.port &&
+ uri.host.casecmp?(observability_uri.host) &&
+ uri.path.downcase.exclude?("auth/start")
+ create_element(url)
+ end
end
private
diff --git a/lib/extracts_ref.rb b/lib/extracts_ref.rb
index dba1aad639..49c9772f76 100644
--- a/lib/extracts_ref.rb
+++ b/lib/extracts_ref.rb
@@ -5,7 +5,8 @@
# Can be extended for different types of repository object, e.g. Project or Snippet
module ExtractsRef
InvalidPathError = Class.new(StandardError)
-
+ BRANCH_REF_TYPE = 'heads'
+ TAG_REF_TYPE = 'tags'
# Given a string containing both a Git tree-ish, such as a branch or tag, and
# a filesystem path joined by forward slashes, attempts to separate the two.
#
@@ -91,7 +92,7 @@ module ExtractsRef
def ref_type
return unless params[:ref_type].present?
- params[:ref_type] == 'tags' ? 'tags' : 'heads'
+ params[:ref_type] == TAG_REF_TYPE ? TAG_REF_TYPE : BRANCH_REF_TYPE
end
private
@@ -154,4 +155,13 @@ module ExtractsRef
def repository_container
raise NotImplementedError
end
+
+ def ambiguous_ref?(project, ref)
+ return true if project.repository.ambiguous_ref?(ref)
+
+ return false unless ref&.starts_with?('refs/')
+
+ unprefixed_ref = ref.sub(%r{^refs/(heads|tags)/}, '')
+ project.repository.commit(unprefixed_ref).present?
+ end
end
diff --git a/lib/gitlab/background_migration/nullify_last_error_from_project_mirror_data.rb b/lib/gitlab/background_migration/nullify_last_error_from_project_mirror_data.rb
new file mode 100644
index 0000000000..6ea5c17353
--- /dev/null
+++ b/lib/gitlab/background_migration/nullify_last_error_from_project_mirror_data.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ # Nullifies last_error value from project_mirror_data table as they
+ # potentially included sensitive data.
+ # https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/3041
+ class NullifyLastErrorFromProjectMirrorData < BatchedMigrationJob
+ feature_category :source_code_management
+ operation_name :update_all
+
+ def perform
+ each_sub_batch { |rel| rel.update_all(last_error: nil) }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/checks/branch_check.rb b/lib/gitlab/checks/branch_check.rb
index e8f13a92ee..fa7c4972c9 100644
--- a/lib/gitlab/checks/branch_check.rb
+++ b/lib/gitlab/checks/branch_check.rb
@@ -42,7 +42,7 @@ module Gitlab
def prohibited_branch_checks
return if deletion?
- if branch_name =~ /\A\h{40}\z/
+ if branch_name =~ %r{\A\h{40}(/|\z)}
raise GitAccess::ForbiddenError, ERROR_MESSAGES[:prohibited_hex_branch_name]
end
end
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 9c1cb8e352..9b041c18da 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -367,12 +367,12 @@ module Gitlab
def foreign_key_exists?(source, target = nil, **options)
# This if block is necessary because foreign_key_exists? is called in down migrations that may execute before
- # the postgres_foreign_keys view had necessary columns added, or even before the view existed.
+ # the postgres_foreign_keys view had necessary columns added.
# In that case, we revert to the previous behavior of this method.
# The behavior in the if block has a bug: it always returns false if the fk being checked has multiple columns.
# This can be removed after init_schema.rb passes 20221122210711_add_columns_to_postgres_foreign_keys.rb
# Tracking issue: https://gitlab.com/gitlab-org/gitlab/-/issues/386796
- if ActiveRecord::Migrator.current_version < 20221122210711
+ unless connection.column_exists?('postgres_foreign_keys', 'constrained_table_name')
return foreign_keys(source).any? do |foreign_key|
tables_match?(target.to_s, foreign_key.to_table.to_s) &&
options_match?(foreign_key.options, options)
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index e054b6df98..e1399b6642 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -262,7 +262,11 @@ module Gitlab
def archive_metadata(ref, storage_path, project_path, format = "tar.gz", append_sha:, path: nil)
ref ||= root_ref
- commit = Gitlab::Git::Commit.find(self, ref)
+
+ commit_id = extract_commit_id_from_ref(ref)
+ return {} if commit_id.nil?
+
+ commit = Gitlab::Git::Commit.find(self, commit_id)
return {} if commit.nil?
prefix = archive_prefix(ref, commit.id, project_path, append_sha: append_sha, path: path)
@@ -1233,6 +1237,26 @@ module Gitlab
def gitaly_delete_refs(*ref_names)
gitaly_ref_client.delete_refs(refs: ref_names) if ref_names.any?
end
+
+ # The order is based on git priority to resolve ambiguous references
+ #
+ # `git show `
+ #
+ # In case of name clashes, it uses this order:
+ # 1. Commit
+ # 2. Tag
+ # 3. Branch
+ def extract_commit_id_from_ref(ref)
+ return ref if Gitlab::Git.commit_id?(ref)
+
+ tag = find_tag(ref)
+ return tag.dereferenced_target.sha if tag
+
+ branch = find_branch(ref)
+ return branch.dereferenced_target.sha if branch
+
+ ref
+ end
end
end
end
diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb
index e76056709e..943218a997 100644
--- a/lib/gitlab/regex.rb
+++ b/lib/gitlab/regex.rb
@@ -448,6 +448,17 @@ module Gitlab
)
}mx.freeze
+ # Code blocks:
+ # ```
+ # Anything, including `>>>` blocks which are ignored by this filter
+ # ```
+ MARKDOWN_CODE_BLOCK_REGEX_UNTRUSTED =
+ '(?P' \
+ '^```\n' \
+ '(?:\n|.)*?' \
+ '\n```\ *$' \
+ ')'.freeze
+
MARKDOWN_HTML_BLOCK_REGEX = %r{
(?
# HTML block:
@@ -461,27 +472,19 @@ module Gitlab
)
}mx.freeze
- MARKDOWN_HTML_COMMENT_LINE_REGEX = %r{
- (?
- # HTML comment line:
- #
+ # HTML comment line:
+ #
+ MARKDOWN_HTML_COMMENT_LINE_REGEX_UNTRUSTED =
+ '(?P' \
+ '^\ *$' \
+ ')'.freeze
- ^\ *$
- )
- }mx.freeze
-
- MARKDOWN_HTML_COMMENT_BLOCK_REGEX = %r{
- (?
- # HTML comment block:
- #
-
- ^\ *$
- )
- }mx.freeze
+ MARKDOWN_HTML_COMMENT_BLOCK_REGEX_UNTRUSTED =
+ '(?P' \
+ '^\ *$' \
+ ')'.freeze
def markdown_code_or_html_blocks
@markdown_code_or_html_blocks ||= %r{
@@ -491,14 +494,13 @@ module Gitlab
}mx.freeze
end
- def markdown_code_or_html_comments
- @markdown_code_or_html_comments ||= %r{
- #{MARKDOWN_CODE_BLOCK_REGEX}
- |
- #{MARKDOWN_HTML_COMMENT_LINE_REGEX}
- |
- #{MARKDOWN_HTML_COMMENT_BLOCK_REGEX}
- }mx.freeze
+ def markdown_code_or_html_comments_untrusted
+ @markdown_code_or_html_comments_untrusted ||=
+ "#{MARKDOWN_CODE_BLOCK_REGEX_UNTRUSTED}" \
+ "|" \
+ "#{MARKDOWN_HTML_COMMENT_LINE_REGEX_UNTRUSTED}" \
+ "|" \
+ "#{MARKDOWN_HTML_COMMENT_BLOCK_REGEX_UNTRUSTED}"
end
# Based on Jira's project key format
diff --git a/lib/gitlab/unicode.rb b/lib/gitlab/unicode.rb
index b49c5647da..f291ea1b4e 100644
--- a/lib/gitlab/unicode.rb
+++ b/lib/gitlab/unicode.rb
@@ -9,6 +9,12 @@ module Gitlab
# https://idiosyncratic-ruby.com/41-proper-unicoding.html
BIDI_REGEXP = /\p{Bidi Control}/.freeze
+ # Regular expression for identifying space characters
+ #
+ # In web browsers space characters can be confused with simple
+ # spaces which may be misleading
+ SPACE_REGEXP = /\p{Space_Separator}/.freeze
+
class << self
# Warning message used to highlight bidi characters in the GUI
def bidi_warning
diff --git a/lib/gitlab/untrusted_regexp.rb b/lib/gitlab/untrusted_regexp.rb
index 96e74f00c7..7c7bda3a8f 100644
--- a/lib/gitlab/untrusted_regexp.rb
+++ b/lib/gitlab/untrusted_regexp.rb
@@ -47,6 +47,17 @@ module Gitlab
RE2.Replace(text, regexp, rewrite)
end
+ # #scan returns an array of the groups captured, rather than MatchData.
+ # Use this to give the capture group name and grab the proper value
+ def extract_named_group(name, match)
+ return unless match
+
+ match_position = regexp.named_capturing_groups[name.to_s]
+ raise RegexpError, "Invalid named capture group: #{name}" unless match_position
+
+ match[match_position - 1]
+ end
+
def ==(other)
self.source == other.source
end
diff --git a/lib/gitlab/url_sanitizer.rb b/lib/gitlab/url_sanitizer.rb
index e3bf11b00b..79e124a58f 100644
--- a/lib/gitlab/url_sanitizer.rb
+++ b/lib/gitlab/url_sanitizer.rb
@@ -2,15 +2,37 @@
module Gitlab
class UrlSanitizer
+ include Gitlab::Utils::StrongMemoize
+
ALLOWED_SCHEMES = %w[http https ssh git].freeze
ALLOWED_WEB_SCHEMES = %w[http https].freeze
+ SCHEMIFIED_SCHEME = 'glschemelessuri'
+ SCHEMIFY_PLACEHOLDER = "#{SCHEMIFIED_SCHEME}://".freeze
+ # URI::DEFAULT_PARSER.make_regexp will only match URLs with schemes or
+ # relative URLs. This section will match schemeless URIs with userinfo
+ # e.g. user:pass@gitlab.com but will not match scp-style URIs e.g.
+ # user@server:path/to/file)
+ #
+ # The userinfo part is very loose compared to URI's implementation so we
+ # also match non-escaped userinfo e.g foo:b?r@gitlab.com which should be
+ # encoded as foo:b%3Fr@gitlab.com
+ URI_REGEXP = %r{
+ (?:
+ #{URI::DEFAULT_PARSER.make_regexp(ALLOWED_SCHEMES)}
+ |
+ (?:(?:(?!@)[%#{URI::REGEXP::PATTERN::UNRESERVED}#{URI::REGEXP::PATTERN::RESERVED}])+(?:@))
+ (?# negative lookahead ensures this isn't an SCP-style URL: [host]:[rel_path|abs_path] server:path/to/file)
+ (?!#{URI::REGEXP::PATTERN::HOST}:(?:#{URI::REGEXP::PATTERN::REL_PATH}|#{URI::REGEXP::PATTERN::ABS_PATH}))
+ #{URI::REGEXP::PATTERN::HOSTPORT}
+ )
+ }x
def self.sanitize(content)
- regexp = URI::DEFAULT_PARSER.make_regexp(ALLOWED_SCHEMES)
-
- content.gsub(regexp) { |url| new(url).masked_url }
- rescue Addressable::URI::InvalidURIError
- content.gsub(regexp, '')
+ content.gsub(URI_REGEXP) do |url|
+ new(url).masked_url
+ rescue Addressable::URI::InvalidURIError
+ ''
+ end
end
def self.valid?(url, allowed_schemes: ALLOWED_SCHEMES)
@@ -37,17 +59,6 @@ module Gitlab
@url = parse_url(url)
end
- def sanitized_url
- @sanitized_url ||= safe_url.to_s
- end
-
- def masked_url
- url = @url.dup
- url.password = "*****" if url.password.present?
- url.user = "*****" if url.user.present?
- url.to_s
- end
-
def credentials
@credentials ||= { user: @url.user.presence, password: @url.password.presence }
end
@@ -56,15 +67,37 @@ module Gitlab
credentials[:user]
end
- def full_url
- @full_url ||= generate_full_url.to_s
+ def sanitized_url
+ safe_url = @url.dup
+ safe_url.password = nil
+ safe_url.user = nil
+ reverse_schemify(safe_url.to_s)
end
+ strong_memoize_attr :sanitized_url
+
+ def masked_url
+ url = @url.dup
+ url.password = "*****" if url.password.present?
+ url.user = "*****" if url.user.present?
+ reverse_schemify(url.to_s)
+ end
+ strong_memoize_attr :masked_url
+
+ def full_url
+ return reverse_schemify(@url.to_s) unless valid_credentials?
+
+ url = @url.dup
+ url.password = encode_percent(credentials[:password]) if credentials[:password].present?
+ url.user = encode_percent(credentials[:user]) if credentials[:user].present?
+ reverse_schemify(url.to_s)
+ end
+ strong_memoize_attr :full_url
private
def parse_url(url)
- url = url.to_s.strip
- match = url.match(%r{\A(?:git|ssh|http(?:s?))\://(?:(.+)(?:@))?(.+)})
+ url = schemify(url.to_s.strip)
+ match = url.match(%r{\A(?:(?:#{SCHEMIFIED_SCHEME}|git|ssh|http(?:s?)):)?//(?:(.+)(?:@))?(.+)}o)
raw_credentials = match[1] if match
if raw_credentials.present?
@@ -83,24 +116,19 @@ module Gitlab
url
end
- def generate_full_url
- return @url unless valid_credentials?
-
- @url.dup.tap do |generated|
- generated.password = encode_percent(credentials[:password]) if credentials[:password].present?
- generated.user = encode_percent(credentials[:user]) if credentials[:user].present?
- end
+ def schemify(url)
+ # Prepend the placeholder scheme unless the URL has a scheme or is relative
+ url.prepend(SCHEMIFY_PLACEHOLDER) unless url.starts_with?(%r{(?:#{URI::REGEXP::PATTERN::SCHEME}:)?//}o)
+ url
end
- def safe_url
- safe_url = @url.dup
- safe_url.password = nil
- safe_url.user = nil
- safe_url
+ def reverse_schemify(url)
+ url.slice!(SCHEMIFY_PLACEHOLDER) if url.starts_with?(SCHEMIFY_PLACEHOLDER)
+ url
end
def valid_credentials?
- credentials && credentials.is_a?(Hash) && credentials.any?
+ credentials.is_a?(Hash) && credentials.values.any?
end
def encode_percent(string)
diff --git a/lib/rouge/formatters/html_gitlab.rb b/lib/rouge/formatters/html_gitlab.rb
index 436739bed1..a7e95a96b8 100644
--- a/lib/rouge/formatters/html_gitlab.rb
+++ b/lib/rouge/formatters/html_gitlab.rb
@@ -25,7 +25,10 @@ module Rouge
yield %()
line.each do |token, value|
- yield highlight_unicode_control_characters(span(token, value.chomp! || value))
+ value = value.chomp! || value
+ value = replace_space_characters(value)
+
+ yield highlight_unicode_control_characters(span(token, value))
end
yield ellipsis if @ellipsis_indexes.include?(@line_number - 1) && @ellipsis_svg.present?
@@ -42,6 +45,10 @@ module Rouge
%(#{@ellipsis_svg})
end
+ def replace_space_characters(text)
+ text.gsub(Gitlab::Unicode::SPACE_REGEXP, ' ')
+ end
+
def highlight_unicode_control_characters(text)
text.gsub(Gitlab::Unicode::BIDI_REGEXP) do |char|
%(#{char})
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 88a17b7d69..695b567039 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -42698,6 +42698,9 @@ msgstr ""
msgid "The default branch for this project has been changed. Please update your bookmarks."
msgstr ""
+msgid "The default branch of this project clashes with another ref"
+msgstr ""
+
msgid "The dependency list details information about the components used within your project."
msgstr ""
diff --git a/scripts/create-pipeline-failure-incident.rb b/scripts/create-pipeline-failure-incident.rb
index 2b86fac680..bd57abf374 100755
--- a/scripts/create-pipeline-failure-incident.rb
+++ b/scripts/create-pipeline-failure-incident.rb
@@ -170,8 +170,6 @@ class CreatePipelineFailureIncident
Additionally, a message can be posted in `#backend_maintainers` or `#frontend_maintainers` to get a maintainer take a look at the fix ASAP.
- Cherry picking a change that was used to fix a similar master-broken issue.
- In both cases, make sure to add the ~"pipeline:expedite" label to speed up the `stable`-fixing pipelines.
-
### Resolution
Add a comment to this issue describing how this incident could have been prevented earlier in the Merge Request pipeline (rather than the merge commit pipeline).
diff --git a/spec/controllers/admin/hooks_controller_spec.rb b/spec/controllers/admin/hooks_controller_spec.rb
index 4101bd7f65..4e68ffdda2 100644
--- a/spec/controllers/admin/hooks_controller_spec.rb
+++ b/spec/controllers/admin/hooks_controller_spec.rb
@@ -59,6 +59,7 @@ RSpec.describe Admin::HooksController do
enable_ssl_verification: false,
url_variables: [
{ key: 'token', value: 'some secret value' },
+ { key: 'baz', value: 'qux' },
{ key: 'foo', value: nil }
]
}
@@ -71,7 +72,7 @@ RSpec.describe Admin::HooksController do
expect(flash[:notice]).to include('was updated')
expect(hook).to have_attributes(hook_params.except(:url_variables))
expect(hook).to have_attributes(
- url_variables: { 'token' => 'some secret value', 'baz' => 'woo' }
+ url_variables: { 'token' => 'some secret value', 'baz' => 'qux' }
)
end
end
diff --git a/spec/controllers/concerns/confirm_email_warning_spec.rb b/spec/controllers/concerns/confirm_email_warning_spec.rb
index 334c156e1a..b8a4b94aa6 100644
--- a/spec/controllers/concerns/confirm_email_warning_spec.rb
+++ b/spec/controllers/concerns/confirm_email_warning_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ConfirmEmailWarning do
+RSpec.describe ConfirmEmailWarning, feature_category: :system_access do
before do
stub_feature_flags(soft_email_confirmation: true)
end
@@ -82,6 +82,38 @@ RSpec.describe ConfirmEmailWarning do
it { is_expected.to set_confirm_warning_for(user.email) }
end
end
+
+ context 'when user is being impersonated' do
+ let(:impersonator) { create(:admin) }
+
+ before do
+ allow(controller).to receive(:session).and_return({ impersonator_id: impersonator.id })
+
+ get :index
+ end
+
+ it { is_expected.to set_confirm_warning_for(user.email) }
+
+ context 'when impersonated user email has html in their email' do
+ let(:user) { create(:user, confirmed_at: nil, unconfirmed_email: "malicious@test.com
-
---B_3664825007_1519466360--
-
-
---B_3664825007_384940722
-Content-type: image/png; name="gitlab_logo.png";
- x-mac-creator="4F50494D";
- x-mac-type="504E4766"
-Content-disposition: attachment;
- filename="gitlab_logo.png"
-Content-transfer-encoding: base64
-
-
-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
---B_3664825007_384940722--
-
---B_3664825007_1904734766
-Content-type: application/pkcs7-signature; name="smime.p7s"
-Content-transfer-encoding: base64
-Content-disposition: attachment;
- filename="smime.p7s"
-
-MIIRpwYJKoZIhvcNAQcCoIIRmDCCEZQCAQExDzANBglghkgBZQMEAgEFADALBgkqhkiG9w0B
-BwGggg8VMIIHojCCBYqgAwIBAgIEZ5a6PTANBgkqhkiG9w0BAQsFADCBtjELMAkGA1UEBhMC
-REUxDzANBgNVBAgMBkJheWVybjERMA8GA1UEBwwITXVlbmNoZW4xEDAOBgNVBAoMB1NpZW1l
-bnMxETAPBgNVBAUTCFpaWlpaWkE2MR0wGwYDVQQLDBRTaWVtZW5zIFRydXN0IENlbnRlcjE/
-MD0GA1UEAww2U2llbWVucyBJc3N1aW5nIENBIE1lZGl1bSBTdHJlbmd0aCBBdXRoZW50aWNh
-dGlvbiAyMDE2MB4XDTE5MTEyMTE0NDQ0N1oXDTIwMTEyMTE0NDQ0N1owdzERMA8GA1UEBRMI
-WjAwM0gwOFQxDjAMBgNVBCoMBURpZWdvMRgwFgYDVQQEDA9Mb3V6YW4gTWFydGluZXoxGDAW
-BgNVBAoMD1NpZW1lbnMtUGFydG5lcjEeMBwGA1UEAwwVTG91emFuIE1hcnRpbmV6IERpZWdv
-MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuInpNaC7NRYD+0pOpHDz2pk9xmPt
-JGj860SF6Nmn6Eu9EMYKEDfneC6z5QcH+mPS2d0VWgqVVGbRXSPsxJtbi9TCWjQUZdHglEZK
-z9zxoFDh2dvW5/+TOT5Jf78FXyqak0YtY6+oMjQ/i9RUqPL7sIlyXLrBYrILzQ9Afo+7bXZg
-v3ypp6xtqAV2ctHzQWFi0onJzxLVYguiVb7fFF9rBEMvSZonuw5tvOwJIhbe5FDFOrDcfbyU
-ofZ/wikIZ+A+CE5GryXuuQmGxJaC2QqOkRAWQDzLDx9nG+rKiEs5OvlfEZC7EV1PyjZ93coM
-faCVdlAgcFZ5fvd37CjyjKl+1QIDAQABo4IC9DCCAvAwggEEBggrBgEFBQcBAQSB9zCB9DAy
-BggrBgEFBQcwAoYmaHR0cDovL2FoLnNpZW1lbnMuY29tL3BraT9aWlpaWlpBNi5jcnQwQQYI
-KwYBBQUHMAKGNWxkYXA6Ly9hbC5zaWVtZW5zLm5ldC9DTj1aWlpaWlpBNixMPVBLST9jQUNl
-cnRpZmljYXRlMEkGCCsGAQUFBzAChj1sZGFwOi8vYWwuc2llbWVucy5jb20vQ049WlpaWlpa
-QTYsbz1UcnVzdGNlbnRlcj9jQUNlcnRpZmljYXRlMDAGCCsGAQUFBzABhiRodHRwOi8vb2Nz
-cC5wa2ktc2VydmljZXMuc2llbWVucy5jb20wHwYDVR0jBBgwFoAU+BVdRwxsd3tyxAIXkWii
-tvdqCUQwDAYDVR0TAQH/BAIwADBFBgNVHSAEPjA8MDoGDSsGAQQBoWkHAgIEAQMwKTAnBggr
-BgEFBQcCARYbaHR0cDovL3d3dy5zaWVtZW5zLmNvbS9wa2kvMIHKBgNVHR8EgcIwgb8wgbyg
-gbmggbaGJmh0dHA6Ly9jaC5zaWVtZW5zLmNvbS9wa2k/WlpaWlpaQTYuY3JshkFsZGFwOi8v
-Y2wuc2llbWVucy5uZXQvQ049WlpaWlpaQTYsTD1QS0k/Y2VydGlmaWNhdGVSZXZvY2F0aW9u
-TGlzdIZJbGRhcDovL2NsLnNpZW1lbnMuY29tL0NOPVpaWlpaWkE2LG89VHJ1c3RjZW50ZXI/
-Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
-AwQwDgYDVR0PAQH/BAQDAgeAMFUGA1UdEQROMEygLAYKKwYBBAGCNxQCA6AeDBxkaWVnby5s
-b3V6YW4uZXh0QHNpZW1lbnMuY29tgRxkaWVnby5sb3V6YW4uZXh0QHNpZW1lbnMuY29tMB0G
-A1UdDgQWBBQj8k8aqZey68w8ALYKGJSGMt5hZDANBgkqhkiG9w0BAQsFAAOCAgEAFDHqxpb1
-R9cB4noC9vx09bkNbmXCpVfl3XCQUmAWTznC0nwEssTTjo0PWuIV4C3jnsp0MRUeHZ6lsyhZ
-OzS1ETwYgvj6wzjb8RF3wgn7N/JOvFGaErMz5HZpKOfzGiNpW6/Rmd4hsRDjAwOVQOXUTqc/
-0Bj3FMoLRCSWSnTp5HdyvrY2xOKHfTrTjzmcLdFaKE2F5n7+dBkwCKVfzut8CqfVq/I7ks4m
-D1IHk93/P6l9U34R2FHPt6zRTNZcWmDirRSlMH4L18CnfiNPuDN/PtRYlt3Vng5EdYN0VCg2
-NM/uees0U4ingCb0NFjg66uQ/tjfPQk55MN4Wpls4N6TkMoTCWLiqZzYTGdmVQexzroL6940
-tmMr8LoN3TpPf0OdvdKEpyH7fzsx5QlmQyywIWec6X+Fx6+l0g91VJnPEtqACpfZIBZtviHl
-gfX298w+SsvBK8C48Pqs8Ijh7tLrCxx7VMLVHZqwWWPK53ga+CDWmjoSQPxi+CPZF7kao6N5
-4GrJWwSHlHh6WzTbLyLvTJZZ775Utp4W8s8xMUsQJ413iYzEaC8FcSeNjSk5UiDDiHrKmzpM
-tbApD3pUXStblUMKYGTG1Mj9BcEBFkCdoGlw/ulszIrKFfOyRNDG3Ay+Dj/oMjoKsJphu3px
-wyft82rTer7UW/I7o0h0DAG4lkMwggdrMIIFU6ADAgECAgR5nlqfMA0GCSqGSIb3DQEBCwUA
-MIGeMQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmF5ZXJuMREwDwYDVQQHDAhNdWVuY2hlbjEQ
-MA4GA1UECgwHU2llbWVuczERMA8GA1UEBRMIWlpaWlpaQTMxHTAbBgNVBAsMFFNpZW1lbnMg
-VHJ1c3QgQ2VudGVyMScwJQYDVQQDDB5TaWVtZW5zIElzc3VpbmcgQ0EgRUUgRW5jIDIwMTYw
-HhcNMTkwOTI3MDgwMTM5WhcNMjAwOTI3MDgwMTM3WjB3MREwDwYDVQQFEwhaMDAzSDA4VDEO
-MAwGA1UEKgwFRGllZ28xGDAWBgNVBAQMD0xvdXphbiBNYXJ0aW5lejEYMBYGA1UECgwPU2ll
-bWVucy1QYXJ0bmVyMR4wHAYDVQQDDBVMb3V6YW4gTWFydGluZXogRGllZ28wggEiMA0GCSqG
-SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCyby5qKzZIrGYWRqxnaAyMt/a/uc0uMk0F3MjwxvPM
-vh5DllUpqx0l8ZDakDjPhlEXTeoL4DHNgmh+CDCs76CppM3cNG/1W1Ajo/L2iwMoXaxYuQ/F
-q7ED+02KEkWX2DDVVG3fhrUGP20QAq77xPDptmVWZnUnuobZBNYkC49Xfl9HJvkJL8P0+Jqb
-Eae7p4roiEr7wNkGriwrVXgA3oPNF/W+OuI76JTNTajS/6PAK/GeqIvLjfuBXpdBZTY031nE
-Cztca8vI1jUjQzVhS+0dWpvpfhkVumbvOnid8DI9lapYsX8dpZFsa3ya+T3tjUdGSOOKi0kg
-lWf/XYyyfhmDAgMBAAGjggLVMIIC0TAdBgNVHQ4EFgQUprhTCDwNLfPImpSfWdq+QvPTo9Mw
-JwYDVR0RBCAwHoEcZGllZ28ubG91emFuLmV4dEBzaWVtZW5zLmNvbTAOBgNVHQ8BAf8EBAMC
-BDAwLAYDVR0lBCUwIwYIKwYBBQUHAwQGCisGAQQBgjcKAwQGCysGAQQBgjcKAwQBMIHKBgNV
-HR8EgcIwgb8wgbyggbmggbaGJmh0dHA6Ly9jaC5zaWVtZW5zLmNvbS9wa2k/WlpaWlpaQTMu
-Y3JshkFsZGFwOi8vY2wuc2llbWVucy5uZXQvQ049WlpaWlpaQTMsTD1QS0k/Y2VydGlmaWNh
-dGVSZXZvY2F0aW9uTGlzdIZJbGRhcDovL2NsLnNpZW1lbnMuY29tL0NOPVpaWlpaWkEzLG89
-VHJ1c3RjZW50ZXI/Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdDBFBgNVHSAEPjA8MDoGDSsG
-AQQBoWkHAgIEAQMwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5zaWVtZW5zLmNvbS9wa2kv
-MAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUoassbqB68NPCTeof8R4hivwMre8wggEEBggr
-BgEFBQcBAQSB9zCB9DAyBggrBgEFBQcwAoYmaHR0cDovL2FoLnNpZW1lbnMuY29tL3BraT9a
-WlpaWlpBMy5jcnQwQQYIKwYBBQUHMAKGNWxkYXA6Ly9hbC5zaWVtZW5zLm5ldC9DTj1aWlpa
-WlpBMyxMPVBLST9jQUNlcnRpZmljYXRlMEkGCCsGAQUFBzAChj1sZGFwOi8vYWwuc2llbWVu
-cy5jb20vQ049WlpaWlpaQTMsbz1UcnVzdGNlbnRlcj9jQUNlcnRpZmljYXRlMDAGCCsGAQUF
-BzABhiRodHRwOi8vb2NzcC5wa2ktc2VydmljZXMuc2llbWVucy5jb20wDQYJKoZIhvcNAQEL
-BQADggIBAF98ZMNg28LgkwdjOdvOGbC1QitsWjZTyotmQESF0nClDLUhb0O5675vVixntbrf
-eB8xy1+KRiadk40GnAIJ0YzmNl4Tav6hPYv9VBWe5olsWG7C4qB3Q/SwhvW/e+owxv1cBra8
-R3oRudiN81eTZQHyNghRephVqQG/dpPYqydoANfIhEpHa79QlpaCAeYl4896AZOS8HYbkDFs
-hLdv7sEHtl79YuSWI1wBjbJl70c0Sb4wLRgCPuHyQj2Uw/vQ5xJlEvBDZAIXXe1TP/nqiuY6
-7nweJbbeqfFE6ZP3kCe+mEIWGSaO0iThZyLGer8fHs1XiEmhhPgvC7P7KodzpXU6+hX+ZzbD
-DxEjFfetV5sh0aNSXG9xx4hZmS9bpImBGR8MvZ7cgxqItvLtY2xvfUbYW244d4RcWesaCDq3
-ZEIo6uCIzOzJAwjUdLIac+lLV0rxiHmb7O3cQ19kjpWDB31hmfrus/TKJ55pBKVWBX5m/mFv
-K8Ep5USpGrNS0EzOP7I1kQZv2VsvAhSxk/m5FMLpDy8T0O8YgbLypTXoeJFWCF6RduSjVsaZ
-lkAtTQYud683pjyOMxJXaQUYGU1PmEYSOonMkVsT9aBcxYkXLp+Ln/+8G0OCYu7dRdwnj+Ut
-7yR/ltxtgDcaFApCb0qBTKbgbqZk1fASmkOp+kbdYmoUMYICVjCCAlICAQEwgb8wgbYxCzAJ
-BgNVBAYTAkRFMQ8wDQYDVQQIDAZCYXllcm4xETAPBgNVBAcMCE11ZW5jaGVuMRAwDgYDVQQK
-DAdTaWVtZW5zMREwDwYDVQQFEwhaWlpaWlpBNjEdMBsGA1UECwwUU2llbWVucyBUcnVzdCBD
-ZW50ZXIxPzA9BgNVBAMMNlNpZW1lbnMgSXNzdWluZyBDQSBNZWRpdW0gU3RyZW5ndGggQXV0
-aGVudGljYXRpb24gMjAxNgIEZ5a6PTANBglghkgBZQMEAgEFAKBpMC8GCSqGSIb3DQEJBDEi
-BCAOR58AbNfSrI+vtMs+dgAQtn3IVZ3RjYC5hz3j9k+6TTAYBgkqhkiG9w0BCQMxCwYJKoZI
-hvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMDAyMTcyMTU2NDdaMA0GCSqGSIb3DQEBAQUABIIB
-AHLSBcFHhNHPevbwqvA2ecuVb/aKnj45CFF6l8esP1H5DRm1ee5qMKuIS84NFuFC9RUENNhW
-DBzsB+BVGz64o1f8QgIklYVrIJ4JZ0q1abNG7NbkVKWIpS3CQo//YWShUTYg+JpKx4YbahGR
-sP5zbufbU4eagrrqBChjPTLy+njdjwCNu0XPykBTKOOf6BMjnS33AYjHJyh83JOY7rw3IDLx
-8POQH4g5EMRpl9354s0rEkIezMt7pfUAsqY3QnQ8hvlE4KTikPQ+tvLMK1l/ffcLAP8BdBNI
-YA3ikb3qCoGNSLKieYzNnBPhNOIJELUtEEaljAFZYMQzMKCbI4JdiDs=
-
---B_3664825007_1904734766--
+User-Agent: Microsoft-MacOutlook/10.22.0.200209
+Date: Mon, 17 Feb 2020 22:56:47 +0100
+Subject: Re: htmltest | test issue (#1)
+From: "Louzan Martinez, Diego (ext) (SI BP R&D ZG)"
+
+To: Administrator / htmltest
+
+Message-ID: <012E37D9-2A3F-4AC8-B79A-871F42914D86@siemens.com>
+Thread-Topic: htmltest | test issue (#1)
+References:
+
+
+In-Reply-To:
+Content-type: multipart/signed;
+ protocol="application/pkcs7-signature";
+ micalg=sha256;
+ boundary="B_3664825007_1904734766"
+MIME-Version: 1.0
+
+--B_3664825007_1904734766
+Content-type: multipart/mixed;
+ boundary="B_3664825007_384940722"
+
+
+--B_3664825007_384940722
+Content-type: multipart/alternative;
+ boundary="B_3664825007_1519466360"
+
+
+--B_3664825007_1519466360
+Content-type: text/plain;
+ charset="UTF-8"
+Content-transfer-encoding: quoted-printable
+
+Me too, with an attachment
+
+=20
+
+From: Administrator
+Reply to: Administrator / htmltest
+Date: Monday, 17 February 2020 at 22:55
+To: "Louzan Martinez, Diego (ext) (SOP IT STG XS)"
+Subject: Re: htmltest | test issue (#1)
+
+=20
+
+Administrator commented:=20
+
+I pity the foo !!!
+
+=E2=80=94=20
+Reply to this email directly or view it on GitLab.=20
+You're receiving this email because of your account on 169.254.169.254. If =
+you'd like to receive fewer emails, you can unsubscribe from this thread or =
+adjust your notification settings.=20
+
+
+--B_3664825007_1519466360
+Content-type: text/html;
+ charset="UTF-8"
+Content-transfer-encoding: quoted-printable
+
+GitLab Me too, with an attachment
From: Administrator <dlouzan.dummy@gma=
+il.com>
Reply to: Administrator / htmltest <dlouzan.dummy+c0=
+34670b1623e617e15a3df64223d363@gmail.com>
Date: Monday, 17 Febr=
+uary 2020 at 22:55
To: "Louzan Martinez, Diego (ext) (SOP IT =
+STG XS)" <diego.louzan.ext@siemens.com>
Subject: Re: ht=
+mltest | test issue (#1)
=
+
Administrator commented:
<=
+div>I pity the foo !!!
=E2=80=94
Reply to this =
+email directly or view it on GitLab.
You're receiving this email because of you=
+r account on 169.254.169.254. If you'd like to receive fewer emails, you can=
+ unsubscribe from this thread or adjust your notific=
+ation settings.
+
+--B_3664825007_1519466360--
+
+
+--B_3664825007_384940722
+Content-type: image/png; name="gitlab_logo.png";
+ x-mac-creator="4F50494D";
+ x-mac-type="504E4766"
+Content-disposition: attachment;
+ filename="gitlab_logo.png"
+Content-transfer-encoding: base64
+
+
+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
+--B_3664825007_384940722--
+
+--B_3664825007_1904734766
+Content-type: application/pkcs7-signature; name="smime.p7s"
+Content-transfer-encoding: base64
+Content-disposition: attachment;
+ filename="smime.p7s"
+
+MIIRpwYJKoZIhvcNAQcCoIIRmDCCEZQCAQExDzANBglghkgBZQMEAgEFADALBgkqhkiG9w0B
+BwGggg8VMIIHojCCBYqgAwIBAgIEZ5a6PTANBgkqhkiG9w0BAQsFADCBtjELMAkGA1UEBhMC
+REUxDzANBgNVBAgMBkJheWVybjERMA8GA1UEBwwITXVlbmNoZW4xEDAOBgNVBAoMB1NpZW1l
+bnMxETAPBgNVBAUTCFpaWlpaWkE2MR0wGwYDVQQLDBRTaWVtZW5zIFRydXN0IENlbnRlcjE/
+MD0GA1UEAww2U2llbWVucyBJc3N1aW5nIENBIE1lZGl1bSBTdHJlbmd0aCBBdXRoZW50aWNh
+dGlvbiAyMDE2MB4XDTE5MTEyMTE0NDQ0N1oXDTIwMTEyMTE0NDQ0N1owdzERMA8GA1UEBRMI
+WjAwM0gwOFQxDjAMBgNVBCoMBURpZWdvMRgwFgYDVQQEDA9Mb3V6YW4gTWFydGluZXoxGDAW
+BgNVBAoMD1NpZW1lbnMtUGFydG5lcjEeMBwGA1UEAwwVTG91emFuIE1hcnRpbmV6IERpZWdv
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuInpNaC7NRYD+0pOpHDz2pk9xmPt
+JGj860SF6Nmn6Eu9EMYKEDfneC6z5QcH+mPS2d0VWgqVVGbRXSPsxJtbi9TCWjQUZdHglEZK
+z9zxoFDh2dvW5/+TOT5Jf78FXyqak0YtY6+oMjQ/i9RUqPL7sIlyXLrBYrILzQ9Afo+7bXZg
+v3ypp6xtqAV2ctHzQWFi0onJzxLVYguiVb7fFF9rBEMvSZonuw5tvOwJIhbe5FDFOrDcfbyU
+ofZ/wikIZ+A+CE5GryXuuQmGxJaC2QqOkRAWQDzLDx9nG+rKiEs5OvlfEZC7EV1PyjZ93coM
+faCVdlAgcFZ5fvd37CjyjKl+1QIDAQABo4IC9DCCAvAwggEEBggrBgEFBQcBAQSB9zCB9DAy
+BggrBgEFBQcwAoYmaHR0cDovL2FoLnNpZW1lbnMuY29tL3BraT9aWlpaWlpBNi5jcnQwQQYI
+KwYBBQUHMAKGNWxkYXA6Ly9hbC5zaWVtZW5zLm5ldC9DTj1aWlpaWlpBNixMPVBLST9jQUNl
+cnRpZmljYXRlMEkGCCsGAQUFBzAChj1sZGFwOi8vYWwuc2llbWVucy5jb20vQ049WlpaWlpa
+QTYsbz1UcnVzdGNlbnRlcj9jQUNlcnRpZmljYXRlMDAGCCsGAQUFBzABhiRodHRwOi8vb2Nz
+cC5wa2ktc2VydmljZXMuc2llbWVucy5jb20wHwYDVR0jBBgwFoAU+BVdRwxsd3tyxAIXkWii
+tvdqCUQwDAYDVR0TAQH/BAIwADBFBgNVHSAEPjA8MDoGDSsGAQQBoWkHAgIEAQMwKTAnBggr
+BgEFBQcCARYbaHR0cDovL3d3dy5zaWVtZW5zLmNvbS9wa2kvMIHKBgNVHR8EgcIwgb8wgbyg
+gbmggbaGJmh0dHA6Ly9jaC5zaWVtZW5zLmNvbS9wa2k/WlpaWlpaQTYuY3JshkFsZGFwOi8v
+Y2wuc2llbWVucy5uZXQvQ049WlpaWlpaQTYsTD1QS0k/Y2VydGlmaWNhdGVSZXZvY2F0aW9u
+TGlzdIZJbGRhcDovL2NsLnNpZW1lbnMuY29tL0NOPVpaWlpaWkE2LG89VHJ1c3RjZW50ZXI/
+Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
+AwQwDgYDVR0PAQH/BAQDAgeAMFUGA1UdEQROMEygLAYKKwYBBAGCNxQCA6AeDBxkaWVnby5s
+b3V6YW4uZXh0QHNpZW1lbnMuY29tgRxkaWVnby5sb3V6YW4uZXh0QHNpZW1lbnMuY29tMB0G
+A1UdDgQWBBQj8k8aqZey68w8ALYKGJSGMt5hZDANBgkqhkiG9w0BAQsFAAOCAgEAFDHqxpb1
+R9cB4noC9vx09bkNbmXCpVfl3XCQUmAWTznC0nwEssTTjo0PWuIV4C3jnsp0MRUeHZ6lsyhZ
+OzS1ETwYgvj6wzjb8RF3wgn7N/JOvFGaErMz5HZpKOfzGiNpW6/Rmd4hsRDjAwOVQOXUTqc/
+0Bj3FMoLRCSWSnTp5HdyvrY2xOKHfTrTjzmcLdFaKE2F5n7+dBkwCKVfzut8CqfVq/I7ks4m
+D1IHk93/P6l9U34R2FHPt6zRTNZcWmDirRSlMH4L18CnfiNPuDN/PtRYlt3Vng5EdYN0VCg2
+NM/uees0U4ingCb0NFjg66uQ/tjfPQk55MN4Wpls4N6TkMoTCWLiqZzYTGdmVQexzroL6940
+tmMr8LoN3TpPf0OdvdKEpyH7fzsx5QlmQyywIWec6X+Fx6+l0g91VJnPEtqACpfZIBZtviHl
+gfX298w+SsvBK8C48Pqs8Ijh7tLrCxx7VMLVHZqwWWPK53ga+CDWmjoSQPxi+CPZF7kao6N5
+4GrJWwSHlHh6WzTbLyLvTJZZ775Utp4W8s8xMUsQJ413iYzEaC8FcSeNjSk5UiDDiHrKmzpM
+tbApD3pUXStblUMKYGTG1Mj9BcEBFkCdoGlw/ulszIrKFfOyRNDG3Ay+Dj/oMjoKsJphu3px
+wyft82rTer7UW/I7o0h0DAG4lkMwggdrMIIFU6ADAgECAgR5nlqfMA0GCSqGSIb3DQEBCwUA
+MIGeMQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmF5ZXJuMREwDwYDVQQHDAhNdWVuY2hlbjEQ
+MA4GA1UECgwHU2llbWVuczERMA8GA1UEBRMIWlpaWlpaQTMxHTAbBgNVBAsMFFNpZW1lbnMg
+VHJ1c3QgQ2VudGVyMScwJQYDVQQDDB5TaWVtZW5zIElzc3VpbmcgQ0EgRUUgRW5jIDIwMTYw
+HhcNMTkwOTI3MDgwMTM5WhcNMjAwOTI3MDgwMTM3WjB3MREwDwYDVQQFEwhaMDAzSDA4VDEO
+MAwGA1UEKgwFRGllZ28xGDAWBgNVBAQMD0xvdXphbiBNYXJ0aW5lejEYMBYGA1UECgwPU2ll
+bWVucy1QYXJ0bmVyMR4wHAYDVQQDDBVMb3V6YW4gTWFydGluZXogRGllZ28wggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCyby5qKzZIrGYWRqxnaAyMt/a/uc0uMk0F3MjwxvPM
+vh5DllUpqx0l8ZDakDjPhlEXTeoL4DHNgmh+CDCs76CppM3cNG/1W1Ajo/L2iwMoXaxYuQ/F
+q7ED+02KEkWX2DDVVG3fhrUGP20QAq77xPDptmVWZnUnuobZBNYkC49Xfl9HJvkJL8P0+Jqb
+Eae7p4roiEr7wNkGriwrVXgA3oPNF/W+OuI76JTNTajS/6PAK/GeqIvLjfuBXpdBZTY031nE
+Cztca8vI1jUjQzVhS+0dWpvpfhkVumbvOnid8DI9lapYsX8dpZFsa3ya+T3tjUdGSOOKi0kg
+lWf/XYyyfhmDAgMBAAGjggLVMIIC0TAdBgNVHQ4EFgQUprhTCDwNLfPImpSfWdq+QvPTo9Mw
+JwYDVR0RBCAwHoEcZGllZ28ubG91emFuLmV4dEBzaWVtZW5zLmNvbTAOBgNVHQ8BAf8EBAMC
+BDAwLAYDVR0lBCUwIwYIKwYBBQUHAwQGCisGAQQBgjcKAwQGCysGAQQBgjcKAwQBMIHKBgNV
+HR8EgcIwgb8wgbyggbmggbaGJmh0dHA6Ly9jaC5zaWVtZW5zLmNvbS9wa2k/WlpaWlpaQTMu
+Y3JshkFsZGFwOi8vY2wuc2llbWVucy5uZXQvQ049WlpaWlpaQTMsTD1QS0k/Y2VydGlmaWNh
+dGVSZXZvY2F0aW9uTGlzdIZJbGRhcDovL2NsLnNpZW1lbnMuY29tL0NOPVpaWlpaWkEzLG89
+VHJ1c3RjZW50ZXI/Y2VydGlmaWNhdGVSZXZvY2F0aW9uTGlzdDBFBgNVHSAEPjA8MDoGDSsG
+AQQBoWkHAgIEAQMwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5zaWVtZW5zLmNvbS9wa2kv
+MAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUoassbqB68NPCTeof8R4hivwMre8wggEEBggr
+BgEFBQcBAQSB9zCB9DAyBggrBgEFBQcwAoYmaHR0cDovL2FoLnNpZW1lbnMuY29tL3BraT9a
+WlpaWlpBMy5jcnQwQQYIKwYBBQUHMAKGNWxkYXA6Ly9hbC5zaWVtZW5zLm5ldC9DTj1aWlpa
+WlpBMyxMPVBLST9jQUNlcnRpZmljYXRlMEkGCCsGAQUFBzAChj1sZGFwOi8vYWwuc2llbWVu
+cy5jb20vQ049WlpaWlpaQTMsbz1UcnVzdGNlbnRlcj9jQUNlcnRpZmljYXRlMDAGCCsGAQUF
+BzABhiRodHRwOi8vb2NzcC5wa2ktc2VydmljZXMuc2llbWVucy5jb20wDQYJKoZIhvcNAQEL
+BQADggIBAF98ZMNg28LgkwdjOdvOGbC1QitsWjZTyotmQESF0nClDLUhb0O5675vVixntbrf
+eB8xy1+KRiadk40GnAIJ0YzmNl4Tav6hPYv9VBWe5olsWG7C4qB3Q/SwhvW/e+owxv1cBra8
+R3oRudiN81eTZQHyNghRephVqQG/dpPYqydoANfIhEpHa79QlpaCAeYl4896AZOS8HYbkDFs
+hLdv7sEHtl79YuSWI1wBjbJl70c0Sb4wLRgCPuHyQj2Uw/vQ5xJlEvBDZAIXXe1TP/nqiuY6
+7nweJbbeqfFE6ZP3kCe+mEIWGSaO0iThZyLGer8fHs1XiEmhhPgvC7P7KodzpXU6+hX+ZzbD
+DxEjFfetV5sh0aNSXG9xx4hZmS9bpImBGR8MvZ7cgxqItvLtY2xvfUbYW244d4RcWesaCDq3
+ZEIo6uCIzOzJAwjUdLIac+lLV0rxiHmb7O3cQ19kjpWDB31hmfrus/TKJ55pBKVWBX5m/mFv
+K8Ep5USpGrNS0EzOP7I1kQZv2VsvAhSxk/m5FMLpDy8T0O8YgbLypTXoeJFWCF6RduSjVsaZ
+lkAtTQYud683pjyOMxJXaQUYGU1PmEYSOonMkVsT9aBcxYkXLp+Ln/+8G0OCYu7dRdwnj+Ut
+7yR/ltxtgDcaFApCb0qBTKbgbqZk1fASmkOp+kbdYmoUMYICVjCCAlICAQEwgb8wgbYxCzAJ
+BgNVBAYTAkRFMQ8wDQYDVQQIDAZCYXllcm4xETAPBgNVBAcMCE11ZW5jaGVuMRAwDgYDVQQK
+DAdTaWVtZW5zMREwDwYDVQQFEwhaWlpaWlpBNjEdMBsGA1UECwwUU2llbWVucyBUcnVzdCBD
+ZW50ZXIxPzA9BgNVBAMMNlNpZW1lbnMgSXNzdWluZyBDQSBNZWRpdW0gU3RyZW5ndGggQXV0
+aGVudGljYXRpb24gMjAxNgIEZ5a6PTANBglghkgBZQMEAgEFAKBpMC8GCSqGSIb3DQEJBDEi
+BCAOR58AbNfSrI+vtMs+dgAQtn3IVZ3RjYC5hz3j9k+6TTAYBgkqhkiG9w0BCQMxCwYJKoZI
+hvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMDAyMTcyMTU2NDdaMA0GCSqGSIb3DQEBAQUABIIB
+AHLSBcFHhNHPevbwqvA2ecuVb/aKnj45CFF6l8esP1H5DRm1ee5qMKuIS84NFuFC9RUENNhW
+DBzsB+BVGz64o1f8QgIklYVrIJ4JZ0q1abNG7NbkVKWIpS3CQo//YWShUTYg+JpKx4YbahGR
+sP5zbufbU4eagrrqBChjPTLy+njdjwCNu0XPykBTKOOf6BMjnS33AYjHJyh83JOY7rw3IDLx
+8POQH4g5EMRpl9354s0rEkIezMt7pfUAsqY3QnQ8hvlE4KTikPQ+tvLMK1l/ffcLAP8BdBNI
+YA3ikb3qCoGNSLKieYzNnBPhNOIJELUtEEaljAFZYMQzMKCbI4JdiDs=
+
+--B_3664825007_1904734766--
diff --git a/spec/frontend/behaviors/markdown/render_observability_spec.js b/spec/frontend/behaviors/markdown/render_observability_spec.js
index c87d11742d..03a0cb2fcc 100644
--- a/spec/frontend/behaviors/markdown/render_observability_spec.js
+++ b/spec/frontend/behaviors/markdown/render_observability_spec.js
@@ -16,7 +16,7 @@ describe('Observability iframe renderer', () => {
});
it('renders an observability iframe', () => {
- document.body.innerHTML = ``;
+ document.body.innerHTML = ``;
expect(findObservabilityIframes()).toHaveLength(0);
@@ -26,7 +26,7 @@ describe('Observability iframe renderer', () => {
});
it('renders iframe with dark param when GL has dark theme', () => {
- document.body.innerHTML = ``;
+ document.body.innerHTML = ``;
jest.spyOn(ColorUtils, 'darkModeEnabled').mockImplementation(() => true);
expect(findObservabilityIframes('dark')).toHaveLength(0);
@@ -35,4 +35,12 @@ describe('Observability iframe renderer', () => {
expect(findObservabilityIframes('dark')).toHaveLength(1);
});
+
+ it('does not render if url is different from observability url', () => {
+ document.body.innerHTML = ``;
+
+ renderEmbeddedObservability();
+
+ expect(findObservabilityIframes()).toHaveLength(0);
+ });
});
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 3f4513e6bf..da51372dd3 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -310,69 +310,58 @@ describe('Description component', () => {
});
});
- describe('with work_items_mvc feature flag enabled', () => {
- describe('empty description', () => {
- beforeEach(() => {
- createComponent({
- props: {
- descriptionHtml: '',
- },
- provide: {
- glFeatures: {
- workItemsMvc: true,
- },
- },
- });
- return nextTick();
- });
-
- it('renders without error', () => {
- expect(findTaskActionButtons()).toHaveLength(0);
+ describe('empty description', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ descriptionHtml: '',
+ },
});
+ return nextTick();
});
- describe('description with checkboxes', () => {
- beforeEach(() => {
- createComponent({
- props: {
- descriptionHtml: descriptionHtmlWithCheckboxes,
- },
- provide: {
- glFeatures: {
- workItemsMvc: true,
- },
- },
- });
- return nextTick();
- });
+ it('renders without error', () => {
+ expect(findTaskActionButtons()).toHaveLength(0);
+ });
+ });
- it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => {
- expect(findTaskActionButtons()).toHaveLength(3);
- });
-
- it('does not show a modal by default', () => {
- expect(findModal().exists()).toBe(false);
- });
-
- it('shows toast after delete success', async () => {
- const newDesc = 'description';
- findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc);
-
- expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]);
- expect($toast.show).toHaveBeenCalledWith('Task deleted');
+ describe('description with checkboxes', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ descriptionHtml: descriptionHtmlWithCheckboxes,
+ },
});
+ return nextTick();
});
- describe('task list item actions', () => {
- describe('converting the task list item to a task', () => {
- describe('when successful', () => {
- let createWorkItemMutationHandler;
+ it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => {
+ expect(findTaskActionButtons()).toHaveLength(3);
+ });
- beforeEach(async () => {
- createWorkItemMutationHandler = jest
- .fn()
- .mockResolvedValue(createWorkItemMutationResponse);
- const descriptionText = `Tasks
+ it('does not show a modal by default', () => {
+ expect(findModal().exists()).toBe(false);
+ });
+
+ it('shows toast after delete success', async () => {
+ const newDesc = 'description';
+ findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc);
+
+ expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]);
+ expect($toast.show).toHaveBeenCalledWith('Task deleted');
+ });
+ });
+
+ describe('task list item actions', () => {
+ describe('converting the task list item to a task', () => {
+ describe('when successful', () => {
+ let createWorkItemMutationHandler;
+
+ beforeEach(async () => {
+ createWorkItemMutationHandler = jest
+ .fn()
+ .mockResolvedValue(createWorkItemMutationResponse);
+ const descriptionText = `Tasks
1. [ ] item 1
1. [ ] item 2
@@ -381,218 +370,207 @@ describe('Description component', () => {
1. [ ] item 3
1. [ ] item 4;`;
- createComponent({
- props: { descriptionText },
- provide: { glFeatures: { workItemsMvc: true } },
- createWorkItemMutationHandler,
- });
- await waitForPromises();
-
- eventHub.$emit('convert-task-list-item', '4:4-8:19');
- await waitForPromises();
+ createComponent({
+ props: { descriptionText },
+ createWorkItemMutationHandler,
});
+ await waitForPromises();
- it('emits an event to update the description with the deleted task list item omitted', () => {
- const newDescriptionText = `Tasks
-
-1. [ ] item 1
- 1. [ ] item 3
- 1. [ ] item 4;`;
-
- expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]);
- });
-
- it('calls a mutation to create a task', () => {
- const {
- confidential,
- iteration,
- milestone,
- } = issueDetailsResponse.data.workspace.issuable;
- expect(createWorkItemMutationHandler).toHaveBeenCalledWith({
- input: {
- confidential,
- description: '\nparagraph text\n',
- hierarchyWidget: {
- parentId: 'gid://gitlab/WorkItem/1',
- },
- iterationWidget: {
- iterationId: IS_EE ? iteration.id : null,
- },
- milestoneWidget: {
- milestoneId: milestone.id,
- },
- projectPath: 'gitlab-org/gitlab-test',
- title: 'item 2',
- workItemTypeId: 'gid://gitlab/WorkItems::Type/3',
- },
- });
- });
-
- it('shows a toast to confirm the creation of the task', () => {
- expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object));
- });
+ eventHub.$emit('convert-task-list-item', '4:4-8:19');
+ await waitForPromises();
});
- describe('when unsuccessful', () => {
- beforeEach(async () => {
- createComponent({
- props: { descriptionText: 'description' },
- provide: { glFeatures: { workItemsMvc: true } },
- createWorkItemMutationHandler: jest
- .fn()
- .mockResolvedValue(createWorkItemMutationErrorResponse),
- });
- await waitForPromises();
-
- eventHub.$emit('convert-task-list-item', '1:1-1:11');
- await waitForPromises();
- });
-
- it('shows an alert with an error message', () => {
- expect(createAlert).toHaveBeenCalledWith({
- message: 'Something went wrong when creating task. Please try again.',
- error: new Error('an error'),
- captureError: true,
- });
- });
- });
- });
-
- describe('deleting the task list item', () => {
- it('emits an event to update the description with the deleted task list item', () => {
- const descriptionText = `Tasks
-
-1. [ ] item 1
- 1. [ ] item 2
- 1. [ ] item 3
- 1. [ ] item 4;`;
+ it('emits an event to update the description with the deleted task list item omitted', () => {
const newDescriptionText = `Tasks
1. [ ] item 1
1. [ ] item 3
1. [ ] item 4;`;
- createComponent({
- props: { descriptionText },
- provide: { glFeatures: { workItemsMvc: true } },
- });
-
- eventHub.$emit('delete-task-list-item', '4:4-5:19');
expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]);
});
- });
- });
- describe('work items detail', () => {
- describe('when opening and closing', () => {
- beforeEach(() => {
+ it('calls a mutation to create a task', () => {
+ const {
+ confidential,
+ iteration,
+ milestone,
+ } = issueDetailsResponse.data.workspace.issuable;
+ expect(createWorkItemMutationHandler).toHaveBeenCalledWith({
+ input: {
+ confidential,
+ description: '\nparagraph text\n',
+ hierarchyWidget: {
+ parentId: 'gid://gitlab/WorkItem/1',
+ },
+ iterationWidget: {
+ iterationId: IS_EE ? iteration.id : null,
+ },
+ milestoneWidget: {
+ milestoneId: milestone.id,
+ },
+ projectPath: 'gitlab-org/gitlab-test',
+ title: 'item 2',
+ workItemTypeId: 'gid://gitlab/WorkItems::Type/3',
+ },
+ });
+ });
+
+ it('shows a toast to confirm the creation of the task', () => {
+ expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object));
+ });
+ });
+
+ describe('when unsuccessful', () => {
+ beforeEach(async () => {
createComponent({
- props: {
- descriptionHtml: descriptionHtmlWithTask,
- },
- provide: {
- glFeatures: { workItemsMvc: true },
- },
+ props: { descriptionText: 'description' },
+ createWorkItemMutationHandler: jest
+ .fn()
+ .mockResolvedValue(createWorkItemMutationErrorResponse),
});
- return nextTick();
+ await waitForPromises();
+
+ eventHub.$emit('convert-task-list-item', '1:1-1:11');
+ await waitForPromises();
});
- it('opens when task button is clicked', async () => {
- await findTaskLink().trigger('click');
-
- expect(showDetailsModal).toHaveBeenCalled();
- expect(updateHistory).toHaveBeenCalledWith({
- url: `${TEST_HOST}/?work_item_id=2`,
- replace: true,
+ it('shows an alert with an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Something went wrong when creating task. Please try again.',
+ error: new Error('an error'),
+ captureError: true,
});
});
-
- it('closes from an open state', async () => {
- await findTaskLink().trigger('click');
-
- findWorkItemDetailModal().vm.$emit('close');
- await nextTick();
-
- expect(updateHistory).toHaveBeenLastCalledWith({
- url: `${TEST_HOST}/`,
- replace: true,
- });
- });
-
- it('tracks when opened', async () => {
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
-
- await findTaskLink().trigger('click');
-
- expect(trackingSpy).toHaveBeenCalledWith(
- TRACKING_CATEGORY_SHOW,
- 'viewed_work_item_from_modal',
- {
- category: TRACKING_CATEGORY_SHOW,
- label: 'work_item_view',
- property: 'type_task',
- },
- );
- });
- });
-
- describe('when url query `work_item_id` exists', () => {
- it.each`
- behavior | workItemId | modalOpened
- ${'opens'} | ${'2'} | ${1}
- ${'does not open'} | ${'123'} | ${0}
- ${'does not open'} | ${'123e'} | ${0}
- ${'does not open'} | ${'12e3'} | ${0}
- ${'does not open'} | ${'1e23'} | ${0}
- ${'does not open'} | ${'x'} | ${0}
- ${'does not open'} | ${'undefined'} | ${0}
- `(
- '$behavior when url contains `work_item_id=$workItemId`',
- async ({ workItemId, modalOpened }) => {
- setWindowLocation(`?work_item_id=${workItemId}`);
-
- createComponent({
- props: { descriptionHtml: descriptionHtmlWithTask },
- provide: { glFeatures: { workItemsMvc: true } },
- });
-
- expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened);
- },
- );
});
});
- describe('when hovering task links', () => {
+ describe('deleting the task list item', () => {
+ it('emits an event to update the description with the deleted task list item', () => {
+ const descriptionText = `Tasks
+
+1. [ ] item 1
+ 1. [ ] item 2
+ 1. [ ] item 3
+ 1. [ ] item 4;`;
+ const newDescriptionText = `Tasks
+
+1. [ ] item 1
+ 1. [ ] item 3
+ 1. [ ] item 4;`;
+ createComponent({
+ props: { descriptionText },
+ });
+
+ eventHub.$emit('delete-task-list-item', '4:4-5:19');
+
+ expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]);
+ });
+ });
+ });
+
+ describe('work items detail', () => {
+ describe('when opening and closing', () => {
beforeEach(() => {
createComponent({
props: {
descriptionHtml: descriptionHtmlWithTask,
},
- provide: {
- glFeatures: { workItemsMvc: true },
- },
});
return nextTick();
});
- it('prefetches work item detail after work item link is hovered for 150ms', async () => {
- await findTaskLink().trigger('mouseover');
- jest.advanceTimersByTime(150);
- await waitForPromises();
+ it('opens when task button is clicked', async () => {
+ await findTaskLink().trigger('click');
- expect(queryHandler).toHaveBeenCalledWith({
- id: 'gid://gitlab/WorkItem/2',
+ expect(showDetailsModal).toHaveBeenCalled();
+ expect(updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?work_item_id=2`,
+ replace: true,
});
});
- it('does not work item detail after work item link is hovered for less than 150ms', async () => {
- await findTaskLink().trigger('mouseover');
- await findTaskLink().trigger('mouseout');
- jest.advanceTimersByTime(150);
- await waitForPromises();
+ it('closes from an open state', async () => {
+ await findTaskLink().trigger('click');
- expect(queryHandler).not.toHaveBeenCalled();
+ findWorkItemDetailModal().vm.$emit('close');
+ await nextTick();
+
+ expect(updateHistory).toHaveBeenLastCalledWith({
+ url: `${TEST_HOST}/`,
+ replace: true,
+ });
});
+
+ it('tracks when opened', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ await findTaskLink().trigger('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(
+ TRACKING_CATEGORY_SHOW,
+ 'viewed_work_item_from_modal',
+ {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'work_item_view',
+ property: 'type_task',
+ },
+ );
+ });
+ });
+
+ describe('when url query `work_item_id` exists', () => {
+ it.each`
+ behavior | workItemId | modalOpened
+ ${'opens'} | ${'2'} | ${1}
+ ${'does not open'} | ${'123'} | ${0}
+ ${'does not open'} | ${'123e'} | ${0}
+ ${'does not open'} | ${'12e3'} | ${0}
+ ${'does not open'} | ${'1e23'} | ${0}
+ ${'does not open'} | ${'x'} | ${0}
+ ${'does not open'} | ${'undefined'} | ${0}
+ `(
+ '$behavior when url contains `work_item_id=$workItemId`',
+ async ({ workItemId, modalOpened }) => {
+ setWindowLocation(`?work_item_id=${workItemId}`);
+
+ createComponent({
+ props: { descriptionHtml: descriptionHtmlWithTask },
+ });
+
+ expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened);
+ },
+ );
+ });
+ });
+
+ describe('when hovering task links', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ descriptionHtml: descriptionHtmlWithTask,
+ },
+ });
+ return nextTick();
+ });
+
+ it('prefetches work item detail after work item link is hovered for 150ms', async () => {
+ await findTaskLink().trigger('mouseover');
+ jest.advanceTimersByTime(150);
+ await waitForPromises();
+
+ expect(queryHandler).toHaveBeenCalledWith({
+ id: 'gid://gitlab/WorkItem/2',
+ });
+ });
+
+ it('does not work item detail after work item link is hovered for less than 150ms', async () => {
+ await findTaskLink().trigger('mouseover');
+ await findTaskLink().trigger('mouseout');
+ jest.advanceTimersByTime(150);
+ await waitForPromises();
+
+ expect(queryHandler).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/repository/utils/ref_switcher_utils_spec.js b/spec/frontend/repository/utils/ref_switcher_utils_spec.js
index 7f708f13ea..220dbf1739 100644
--- a/spec/frontend/repository/utils/ref_switcher_utils_spec.js
+++ b/spec/frontend/repository/utils/ref_switcher_utils_spec.js
@@ -1,5 +1,6 @@
import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils';
import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'spec/test_constants';
import { refWithSpecialCharMock, encodedRefWithSpecialCharMock } from '../mock_data';
const projectRootPath = 'root/Project1';
@@ -16,16 +17,38 @@ describe('generateRefDestinationPath', () => {
${`${projectRootPath}/-/blob/${currentRef}/dir1/test.js`} | ${`${projectRootPath}/-/blob/${selectedRef}/dir1/test.js`}
${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js`} | ${`${projectRootPath}/-/blob/${selectedRef}/dir1/dir2/test.js`}
${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js#L123`} | ${`${projectRootPath}/-/blob/${selectedRef}/dir1/dir2/test.js#L123`}
- `('generates the correct destination path for $currentPath', ({ currentPath, result }) => {
+ `('generates the correct destination path for $currentPath', ({ currentPath, result }) => {
setWindowLocation(currentPath);
- expect(generateRefDestinationPath(projectRootPath, currentRef, selectedRef)).toBe(result);
+ expect(generateRefDestinationPath(projectRootPath, currentRef, selectedRef)).toBe(
+ `${TEST_HOST}/${result}`,
+ );
+ });
+
+ describe('when using symbolic ref names', () => {
+ it.each`
+ currentPath | nextRef | result
+ ${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js#L123`} | ${'someHash'} | ${`${projectRootPath}/-/blob/someHash/dir1/dir2/test.js#L123`}
+ ${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js#L123`} | ${'refs/heads/prefixedByUseSymbolicRefNames'} | ${`${projectRootPath}/-/blob/prefixedByUseSymbolicRefNames/dir1/dir2/test.js?ref_type=heads#L123`}
+ ${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js#L123`} | ${'refs/tags/prefixedByUseSymbolicRefNames'} | ${`${projectRootPath}/-/blob/prefixedByUseSymbolicRefNames/dir1/dir2/test.js?ref_type=tags#L123`}
+ ${`${projectRootPath}/-/tree/${currentRef}/dir1/dir2/test.js#L123`} | ${'refs/heads/prefixedByUseSymbolicRefNames'} | ${`${projectRootPath}/-/tree/prefixedByUseSymbolicRefNames/dir1/dir2/test.js?ref_type=heads#L123`}
+ ${`${projectRootPath}/-/tree/${currentRef}/dir1/dir2/test.js#L123`} | ${'refs/tags/prefixedByUseSymbolicRefNames'} | ${`${projectRootPath}/-/tree/prefixedByUseSymbolicRefNames/dir1/dir2/test.js?ref_type=tags#L123`}
+ ${`${projectRootPath}/-/tree/${currentRef}/dir1/dir2/test.js#L123`} | ${'refs/heads/refs/heads/branchNameContainsPrefix'} | ${`${projectRootPath}/-/tree/refs/heads/branchNameContainsPrefix/dir1/dir2/test.js?ref_type=heads#L123`}
+ `(
+ 'generates the correct destination path for $currentPath with ref type when it can be extracted',
+ ({ currentPath, result, nextRef }) => {
+ setWindowLocation(currentPath);
+ expect(generateRefDestinationPath(projectRootPath, currentRef, nextRef)).toBe(
+ `${TEST_HOST}/${result}`,
+ );
+ },
+ );
});
it('encodes the selected ref', () => {
const result = `${projectRootPath}/-/tree/${encodedRefWithSpecialCharMock}`;
expect(generateRefDestinationPath(projectRootPath, currentRef, refWithSpecialCharMock)).toBe(
- result,
+ `${TEST_HOST}/${result}`,
);
});
});
diff --git a/spec/graphql/mutations/ci/runner/update_spec.rb b/spec/graphql/mutations/ci/runner/update_spec.rb
index e0c8219e0f..50351321be 100644
--- a/spec/graphql/mutations/ci/runner/update_spec.rb
+++ b/spec/graphql/mutations/ci/runner/update_spec.rb
@@ -46,10 +46,17 @@ RSpec.describe Mutations::Ci::Runner::Update, feature_category: :runner_fleet do
end
end
- context 'when user can update runner', :enable_admin_mode do
- let_it_be(:admin_user) { create(:user, :admin) }
+ context 'when user can update runner' do
+ let_it_be(:user) { create(:user) }
- let(:current_ctx) { { current_user: admin_user } }
+ let(:original_projects) { [project1, project2] }
+ let(:projects_with_maintainer_access) { original_projects }
+
+ let(:current_ctx) { { current_user: user } }
+
+ before do
+ projects_with_maintainer_access.each { |project| project.add_maintainer(user) }
+ end
context 'with valid arguments' do
let(:mutation_params) do
@@ -82,27 +89,22 @@ RSpec.describe Mutations::Ci::Runner::Update, feature_category: :runner_fleet do
context 'with associatedProjects argument' do
let_it_be(:project3) { create(:project) }
+ let_it_be(:project4) { create(:project) }
+
+ let(:new_projects) { [project3, project4] }
+ let(:mutation_params) do
+ {
+ id: runner.to_global_id,
+ description: 'updated description',
+ associated_projects: new_projects.map { |project| project.to_global_id.to_s }
+ }
+ end
context 'with id set to project runner' do
- let(:mutation_params) do
- {
- id: runner.to_global_id,
- description: 'updated description',
- associated_projects: [project3.to_global_id.to_s]
- }
- end
+ let(:projects_with_maintainer_access) { original_projects + new_projects }
it 'updates runner attributes and project relationships', :aggregate_failures do
- expect_next_instance_of(
- ::Ci::Runners::SetRunnerAssociatedProjectsService,
- {
- runner: runner,
- current_user: admin_user,
- project_ids: [project3.id]
- }
- ) do |service|
- expect(service).to receive(:execute).and_call_original
- end
+ setup_service_expectations
expected_attributes = mutation_params.except(:id, :associated_projects)
@@ -112,57 +114,32 @@ RSpec.describe Mutations::Ci::Runner::Update, feature_category: :runner_fleet do
expect(response[:runner]).to be_an_instance_of(Ci::Runner)
expect(response[:runner]).to have_attributes(expected_attributes)
expect(runner.reload).to have_attributes(expected_attributes)
- expect(runner.projects).to match_array([project1, project3])
+ expect(runner.projects).to match_array([project1] + new_projects)
end
- context 'with user not allowed to assign runner' do
- before do
- allow(admin_user).to receive(:can?).with(:assign_runner, runner).and_return(false)
- end
+ context 'with missing permissions on one of the new projects' do
+ let(:projects_with_maintainer_access) { original_projects + [project3] }
it 'does not update runner', :aggregate_failures do
- expect_next_instance_of(
- ::Ci::Runners::SetRunnerAssociatedProjectsService,
- {
- runner: runner,
- current_user: admin_user,
- project_ids: [project3.id]
- }
- ) do |service|
- expect(service).to receive(:execute).and_call_original
- end
+ setup_service_expectations
expected_attributes = mutation_params.except(:id, :associated_projects)
response
- expect(response[:errors]).to match_array(['user not allowed to assign runner'])
+ expect(response[:errors]).to match_array(['user is not authorized to add runners to project'])
expect(response[:runner]).to be_nil
expect(runner.reload).not_to have_attributes(expected_attributes)
- expect(runner.projects).to match_array([project1, project2])
+ expect(runner.projects).to match_array(original_projects)
end
end
end
context 'with an empty list of projects' do
- let(:mutation_params) do
- {
- id: runner.to_global_id,
- associated_projects: []
- }
- end
+ let(:new_projects) { [] }
it 'removes project relationships', :aggregate_failures do
- expect_next_instance_of(
- ::Ci::Runners::SetRunnerAssociatedProjectsService,
- {
- runner: runner,
- current_user: admin_user,
- project_ids: []
- }
- ) do |service|
- expect(service).to receive(:execute).and_call_original
- end
+ setup_service_expectations
response
@@ -172,15 +149,9 @@ RSpec.describe Mutations::Ci::Runner::Update, feature_category: :runner_fleet do
end
end
- context 'with id set to instance runner' do
- let(:instance_runner) { create(:ci_runner, :instance) }
- let(:mutation_params) do
- {
- id: instance_runner.to_global_id,
- description: 'updated description',
- associated_projects: [project2.to_global_id.to_s]
- }
- end
+ context 'with id set to instance runner', :enable_admin_mode do
+ let_it_be(:user) { create(:user, :admin) }
+ let_it_be(:runner) { create(:ci_runner, :instance) }
it 'raises error', :aggregate_failures do
expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do
@@ -188,6 +159,19 @@ RSpec.describe Mutations::Ci::Runner::Update, feature_category: :runner_fleet do
end
end
end
+
+ def setup_service_expectations
+ expect_next_instance_of(
+ ::Ci::Runners::SetRunnerAssociatedProjectsService,
+ {
+ runner: runner,
+ current_user: user,
+ project_ids: new_projects.map(&:id)
+ }
+ ) do |service|
+ expect(service).to receive(:execute).and_call_original
+ end
+ end
end
context 'with non-existing project ID in associatedProjects argument' do
diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb
index cef72d24c4..bf23c74c0f 100644
--- a/spec/helpers/avatars_helper_spec.rb
+++ b/spec/helpers/avatars_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe AvatarsHelper do
+RSpec.describe AvatarsHelper, feature_category: :source_code_management do
include UploadHelpers
let_it_be(:user) { create(:user) }
@@ -88,7 +88,7 @@ RSpec.describe AvatarsHelper do
describe '#avatar_icon_for' do
let!(:user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: 'bar@example.com') }
let(:email) { 'foo@example.com' }
- let!(:another_user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: email) }
+ let!(:another_user) { create(:user, :public_email, avatar: File.open(uploaded_image_temp_path), email: email) }
it 'prefers the user to retrieve the avatar_url' do
expect(helper.avatar_icon_for(user, email).to_s)
@@ -102,7 +102,7 @@ RSpec.describe AvatarsHelper do
end
describe '#avatar_icon_for_email', :clean_gitlab_redis_cache do
- let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
+ let(:user) { create(:user, :public_email, avatar: File.open(uploaded_image_temp_path)) }
subject { helper.avatar_icon_for_email(user.email).to_s }
@@ -114,6 +114,14 @@ RSpec.describe AvatarsHelper do
end
end
+ context 'when a private email is used' do
+ it 'calls gravatar_icon' do
+ expect(helper).to receive(:gravatar_icon).with(user.commit_email, 20, 2)
+
+ helper.avatar_icon_for_email(user.commit_email, 20, 2)
+ end
+ end
+
context 'when no user exists for the email' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
@@ -136,7 +144,7 @@ RSpec.describe AvatarsHelper do
it_behaves_like "returns avatar for email"
it "caches the request" do
- expect(User).to receive(:find_by_any_email).once.and_call_original
+ expect(User).to receive(:with_public_email).once.and_call_original
expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url)
expect(helper.avatar_icon_for_email(user.email).to_s).to eq(user.avatar.url)
diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb
index a6cfbfe86c..d8fa64e099 100644
--- a/spec/helpers/hooks_helper_spec.rb
+++ b/spec/helpers/hooks_helper_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe HooksHelper do
it 'returns proper data' do
expect(subject).to match(
url: project_hook.url,
- url_variables: Gitlab::Json.dump([{ key: 'abc' }])
+ url_variables: Gitlab::Json.dump([{ key: 'abc' }, { key: 'def' }])
)
end
end
diff --git a/spec/lib/banzai/filter/asset_proxy_filter_spec.rb b/spec/lib/banzai/filter/asset_proxy_filter_spec.rb
index 004c70c28f..dc6ac52a8c 100644
--- a/spec/lib/banzai/filter/asset_proxy_filter_spec.rb
+++ b/spec/lib/banzai/filter/asset_proxy_filter_spec.rb
@@ -80,6 +80,15 @@ RSpec.describe Banzai::Filter::AssetProxyFilter, feature_category: :team_plannin
expect(doc.at_css('img')['data-canonical-src']).to eq src
end
+ it 'replaces invalid URLs' do
+ src = '///example.com/test.png'
+ new_src = 'https://assets.example.com/3368d2c7b9bed775bdd1e811f36a4b80a0dcd8ab/2f2f2f6578616d706c652e636f6d2f746573742e706e67'
+ doc = filter(image(src), @context)
+
+ expect(doc.at_css('img')['src']).to eq new_src
+ expect(doc.at_css('img')['data-canonical-src']).to eq src
+ end
+
it 'skips internal images' do
src = "#{Gitlab.config.gitlab.url}/test.png"
doc = filter(image(src), @context)
diff --git a/spec/lib/banzai/filter/commit_trailers_filter_spec.rb b/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
index 3ebe079897..896f3beb7c 100644
--- a/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
+++ b/spec/lib/banzai/filter/commit_trailers_filter_spec.rb
@@ -218,7 +218,7 @@ RSpec.describe Banzai::Filter::CommitTrailersFilter, feature_category: :source_c
# any path-only link will automatically be prefixed
# with the path of its repository.
# See: "build_relative_path" in "lib/banzai/filter/relative_link_filter.rb"
- let(:user_with_avatar) { create(:user, :with_avatar, username: 'foobar') }
+ let(:user_with_avatar) { create(:user, :public_email, :with_avatar, username: 'foobar') }
it 'returns a full path for avatar urls' do
_, message_html = build_commit_message(
diff --git a/spec/lib/banzai/filter/inline_observability_filter_spec.rb b/spec/lib/banzai/filter/inline_observability_filter_spec.rb
index fb1ba46e76..69a9dc96c2 100644
--- a/spec/lib/banzai/filter/inline_observability_filter_spec.rb
+++ b/spec/lib/banzai/filter/inline_observability_filter_spec.rb
@@ -34,6 +34,58 @@ RSpec.describe Banzai::Filter::InlineObservabilityFilter do
end
end
+ context 'when the document contains an embeddable observability link with redirect' do
+ let(:url) { 'https://observe.gitlab.com@example.com/12345' }
+
+ it 'leaves the original link unchanged' do
+ expect(doc.at_css('a').to_s).to eq(input)
+ end
+
+ it 'does not append an observability charts placeholder' do
+ node = doc.at_css('.js-render-observability')
+
+ expect(node).not_to be_present
+ end
+ end
+
+ context 'when the document contains an embeddable observability link with different port' do
+ let(:url) { 'https://observe.gitlab.com:3000/12345' }
+ let(:observe_url) { 'https://observe.gitlab.com:3001' }
+
+ before do
+ stub_env('OVERRIDE_OBSERVABILITY_URL', observe_url)
+ end
+
+ it 'leaves the original link unchanged' do
+ expect(doc.at_css('a').to_s).to eq(input)
+ end
+
+ it 'does not append an observability charts placeholder' do
+ node = doc.at_css('.js-render-observability')
+
+ expect(node).not_to be_present
+ end
+ end
+
+ context 'when the document contains an embeddable observability link with auth/start' do
+ let(:url) { 'https://observe.gitlab.com/auth/start' }
+ let(:observe_url) { 'https://observe.gitlab.com' }
+
+ before do
+ stub_env('OVERRIDE_OBSERVABILITY_URL', observe_url)
+ end
+
+ it 'leaves the original link unchanged' do
+ expect(doc.at_css('a').to_s).to eq(input)
+ end
+
+ it 'does not append an observability charts placeholder' do
+ node = doc.at_css('.js-render-observability')
+
+ expect(node).not_to be_present
+ end
+ end
+
context 'when feature flag is disabled' do
let(:url) { 'https://observe.gitlab.com/12345' }
diff --git a/spec/lib/gitlab/background_migration/nullify_last_error_from_project_mirror_data_spec.rb b/spec/lib/gitlab/background_migration/nullify_last_error_from_project_mirror_data_spec.rb
new file mode 100644
index 0000000000..62f908ed79
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/nullify_last_error_from_project_mirror_data_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::NullifyLastErrorFromProjectMirrorData, feature_category: :source_code_management do # rubocop:disable Layout/LineLength
+ it 'nullifies last_error column on all rows' do
+ namespaces = table(:namespaces)
+ projects = table(:projects)
+ project_import_states = table(:project_mirror_data)
+
+ group = namespaces.create!(name: 'gitlab', path: 'gitlab-org')
+
+ project_namespace_1 = namespaces.create!(name: 'gitlab', path: 'gitlab-org')
+ project_namespace_2 = namespaces.create!(name: 'gitlab', path: 'gitlab-org')
+ project_namespace_3 = namespaces.create!(name: 'gitlab', path: 'gitlab-org')
+
+ project_1 = projects.create!(
+ namespace_id: group.id,
+ project_namespace_id: project_namespace_1.id,
+ name: 'test1'
+ )
+ project_2 = projects.create!(
+ namespace_id: group.id,
+ project_namespace_id: project_namespace_2.id,
+ name: 'test2'
+ )
+ project_3 = projects.create!(
+ namespace_id: group.id,
+ project_namespace_id: project_namespace_3.id,
+ name: 'test3'
+ )
+
+ project_import_state_1 = project_import_states.create!(
+ project_id: project_1.id,
+ status: 0,
+ last_update_started_at: 1.hour.ago,
+ last_update_scheduled_at: 1.hour.ago,
+ last_update_at: 1.hour.ago,
+ last_successful_update_at: 2.days.ago,
+ last_error: '13:fetch remote: "fatal: unable to look up user:pass@gitlab.com (port 9418) (nodename nor servname provided, or not known)\n": exit status 128.', # rubocop:disable Layout/LineLength
+ correlation_id_value: SecureRandom.uuid,
+ jid: SecureRandom.uuid
+ )
+
+ project_import_states.create!(
+ project_id: project_2.id,
+ status: 1,
+ last_update_started_at: 1.hour.ago,
+ last_update_scheduled_at: 1.hour.ago,
+ last_update_at: 1.hour.ago,
+ last_successful_update_at: nil,
+ next_execution_timestamp: 1.day.from_now,
+ last_error: '',
+ correlation_id_value: SecureRandom.uuid,
+ jid: SecureRandom.uuid
+ )
+
+ project_import_state_3 = project_import_states.create!(
+ project_id: project_3.id,
+ status: 2,
+ last_update_started_at: 1.hour.ago,
+ last_update_scheduled_at: 1.hour.ago,
+ last_update_at: 1.hour.ago,
+ last_successful_update_at: 1.hour.ago,
+ next_execution_timestamp: 1.day.from_now,
+ last_error: nil,
+ correlation_id_value: SecureRandom.uuid,
+ jid: SecureRandom.uuid
+ )
+
+ migration = described_class.new(
+ start_id: project_import_state_1.id,
+ end_id: project_import_state_3.id,
+ batch_table: :project_mirror_data,
+ batch_column: :id,
+ sub_batch_size: 1,
+ pause_ms: 0,
+ connection: ApplicationRecord.connection
+ )
+
+ w_last_error_count = -> { project_import_states.where.not(last_error: nil).count } # rubocop:disable CodeReuse/ActiveRecord
+ expect { migration.perform }.to change(&w_last_error_count).from(2).to(0)
+ end
+end
diff --git a/spec/lib/gitlab/checks/branch_check_spec.rb b/spec/lib/gitlab/checks/branch_check_spec.rb
index d6280d3c28..7f535e86d6 100644
--- a/spec/lib/gitlab/checks/branch_check_spec.rb
+++ b/spec/lib/gitlab/checks/branch_check_spec.rb
@@ -26,8 +26,14 @@ RSpec.describe Gitlab::Checks::BranchCheck do
expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, "You cannot create a branch with a 40-character hexadecimal branch name.")
end
+ it "prohibits 40-character hexadecimal branch names as the start of a path" do
+ allow(subject).to receive(:branch_name).and_return("267208abfe40e546f5e847444276f7d43a39503e/test")
+
+ expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, "You cannot create a branch with a 40-character hexadecimal branch name.")
+ end
+
it "doesn't prohibit a nested hexadecimal in a branch name" do
- allow(subject).to receive(:branch_name).and_return("fix-267208abfe40e546f5e847444276f7d43a39503e")
+ allow(subject).to receive(:branch_name).and_return("267208abfe40e546f5e847444276f7d43a39503e-fix")
expect { subject.validate! }.not_to raise_error
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 72043ba2a2..a842370371 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -116,7 +116,8 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
let(:expected_extension) { 'tar.gz' }
let(:expected_filename) { "#{expected_prefix}.#{expected_extension}" }
let(:expected_path) { File.join(storage_path, cache_key, "@v2", expected_filename) }
- let(:expected_prefix) { "gitlab-git-test-#{ref}-#{TestEnv::BRANCH_SHA['master']}" }
+ let(:expected_prefix) { "gitlab-git-test-#{ref.tr('/', '-')}-#{expected_prefix_sha}" }
+ let(:expected_prefix_sha) { TestEnv::BRANCH_SHA['master'] }
subject(:metadata) { repository.archive_metadata(ref, storage_path, 'gitlab-git-test', format, append_sha: append_sha, path: path) }
@@ -173,6 +174,73 @@ RSpec.describe Gitlab::Git::Repository, feature_category: :source_code_managemen
it { expect(metadata['ArchivePath']).to eq(expected_path) }
end
end
+
+ context 'when references are ambiguous' do
+ let_it_be(:ambiguous_project) { create(:project, :repository) }
+ let_it_be(:repository) { ambiguous_project.repository.raw }
+ let_it_be(:branch_merged_commit_id) { ambiguous_project.repository.find_branch('branch-merged').dereferenced_target.id }
+ let_it_be(:branch_master_commit_id) { ambiguous_project.repository.find_branch('master').dereferenced_target.id }
+ let_it_be(:tag_1_0_0_commit_id) { ambiguous_project.repository.find_tag('v1.0.0').dereferenced_target.id }
+
+ context 'when tag is ambiguous' do
+ before do
+ ambiguous_project.repository.add_tag(user, ref, 'master', 'foo')
+ end
+
+ after do
+ ambiguous_project.repository.rm_tag(user, ref)
+ end
+
+ where(:ref, :expected_commit_id, :desc) do
+ 'refs/heads/branch-merged' | ref(:branch_master_commit_id) | 'when tag looks like a branch'
+ 'branch-merged' | ref(:branch_master_commit_id) | 'when tag has the same name as a branch'
+ ref(:branch_merged_commit_id) | ref(:branch_merged_commit_id) | 'when tag looks like a commit id'
+ 'v0.0.0' | ref(:branch_master_commit_id) | 'when tag looks like a normal tag'
+ end
+
+ with_them do
+ it 'selects the correct commit' do
+ expect(metadata['CommitId']).to eq(expected_commit_id)
+ end
+ end
+ end
+
+ context 'when branch is ambiguous' do
+ before do
+ ambiguous_project.repository.add_branch(user, ref, 'master')
+ end
+
+ where(:ref, :expected_commit_id, :desc) do
+ 'refs/tags/v1.0.0' | ref(:branch_master_commit_id) | 'when branch looks like a tag'
+ 'v1.0.0' | ref(:tag_1_0_0_commit_id) | 'when branch has the same name as a tag'
+ ref(:branch_merged_commit_id) | ref(:branch_merged_commit_id) | 'when branch looks like a commit id'
+ 'just-a-normal-branch' | ref(:branch_master_commit_id) | 'when branch looks like a normal branch'
+ end
+
+ with_them do
+ it 'selects the correct commit' do
+ expect(metadata['CommitId']).to eq(expected_commit_id)
+ end
+ end
+ end
+
+ context 'when ref is HEAD' do
+ let(:ref) { 'HEAD' }
+
+ it 'selects commit id from HEAD ref' do
+ expect(metadata['CommitId']).to eq(branch_master_commit_id)
+ expect(metadata['ArchivePrefix']).to eq(expected_prefix)
+ end
+ end
+
+ context 'when ref is not found' do
+ let(:ref) { 'unknown-ref-cannot-be-found' }
+
+ it 'returns empty metadata' do
+ expect(metadata).to eq({})
+ end
+ end
+ end
end
describe '#size' do
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index caca33704d..bc0f9e22d5 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -1140,9 +1140,9 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
end
context 'HTML comment lines' do
- subject { described_class::MARKDOWN_HTML_COMMENT_LINE_REGEX }
+ subject { Gitlab::UntrustedRegexp.new(described_class::MARKDOWN_HTML_COMMENT_LINE_REGEX_UNTRUSTED, multiline: true) }
- let(:expected) { %() }
+ let(:expected) { [[''], ['']] }
let(:markdown) do
<<~MARKDOWN
Regular text
@@ -1150,26 +1150,28 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do
more text
+
+
MARKDOWN
end
it { is_expected.to match(%()) }
it { is_expected.not_to match(%()) }
it { is_expected.not_to match(%(must start in first column )) }
- it { expect(subject.match(markdown)[:html_comment_line]).to eq expected }
+ it { expect(subject.scan(markdown)).to eq expected }
end
context 'HTML comment blocks' do
- subject { described_class::MARKDOWN_HTML_COMMENT_BLOCK_REGEX }
+ subject { Gitlab::UntrustedRegexp.new(described_class::MARKDOWN_HTML_COMMENT_BLOCK_REGEX_UNTRUSTED, multiline: true) }
- let(:expected) { %() }
+ let(:expected) { %() }
let(:markdown) do
<<~MARKDOWN
Regular text
+ more text -->
MARKDOWN
end
diff --git a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb
index 2862bcc971..a15dbccc80 100644
--- a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb
+++ b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb
@@ -28,7 +28,8 @@ RSpec.describe ::Gitlab::Seeders::Ci::Runner::RunnerFleetPipelineSeeder, feature
context 'with job_count specified' do
let(:job_count) { 20 }
- it 'creates expected jobs', :aggregate_failures do
+ it 'creates expected jobs', :aggregate_failures,
+ quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/394721' do
expect { seeder.seed }.to change { Ci::Build.count }.by(job_count)
.and change { Ci::Pipeline.count }.by(4)
diff --git a/spec/lib/gitlab/untrusted_regexp_spec.rb b/spec/lib/gitlab/untrusted_regexp_spec.rb
index 270c4beec9..66675b2010 100644
--- a/spec/lib/gitlab/untrusted_regexp_spec.rb
+++ b/spec/lib/gitlab/untrusted_regexp_spec.rb
@@ -137,6 +137,38 @@ RSpec.describe Gitlab::UntrustedRegexp do
end
end
+ describe '#extract_named_group' do
+ let(:re) { described_class.new('(?P\w+) (?P\d+)|(?P\w+)') }
+ let(:text) { 'Bob 40' }
+
+ it 'returns values for both named groups' do
+ matched = re.scan(text).first
+
+ expect(re.extract_named_group(:name, matched)).to eq 'Bob'
+ expect(re.extract_named_group(:age, matched)).to eq '40'
+ end
+
+ it 'returns nil if there was no match for group' do
+ matched = re.scan('Bob').first
+
+ expect(re.extract_named_group(:name, matched)).to be_nil
+ expect(re.extract_named_group(:age, matched)).to be_nil
+ expect(re.extract_named_group(:name_only, matched)).to eq 'Bob'
+ end
+
+ it 'returns nil if match is nil' do
+ matched = '(?P\d+)'.scan(text).first
+
+ expect(re.extract_named_group(:age, matched)).to be_nil
+ end
+
+ it 'raises if name is not a capture group' do
+ matched = re.scan(text).first
+
+ expect { re.extract_named_group(:foo, matched) }.to raise_error('Invalid named capture group: foo')
+ end
+ end
+
describe '#match' do
context 'when there are matches' do
it 'returns a match object' do
diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb
index 0ffbf5f81e..c02cbef832 100644
--- a/spec/lib/gitlab/url_sanitizer_spec.rb
+++ b/spec/lib/gitlab/url_sanitizer_spec.rb
@@ -10,29 +10,36 @@ RSpec.describe Gitlab::UrlSanitizer do
# We want to try with multi-line content because is how error messages are formatted
described_class.sanitize(%Q{
remote: Not Found
- fatal: repository '#{url}' not found
+ fatal: repository `#{url}` not found
})
end
where(:input, :output) do
- 'http://user:pass@test.com/root/repoC.git/' | 'http://*****:*****@test.com/root/repoC.git/'
- 'https://user:pass@test.com/root/repoA.git/' | 'https://*****:*****@test.com/root/repoA.git/'
- 'ssh://user@host.test/path/to/repo.git' | 'ssh://*****@host.test/path/to/repo.git'
-
- # git protocol does not support authentication but clean any details anyway
- 'git://user:pass@host.test/path/to/repo.git' | 'git://*****:*****@host.test/path/to/repo.git'
- 'git://host.test/path/to/repo.git' | 'git://host.test/path/to/repo.git'
+ # http(s), ssh, git, relative, and schemeless URLs should all be masked correctly
+ urls = ['http://', 'https://', 'ssh://', 'git://', '//', ''].flat_map do |protocol|
+ [
+ ["#{protocol}test.com", "#{protocol}test.com"],
+ ["#{protocol}test.com/", "#{protocol}test.com/"],
+ ["#{protocol}test.com/path/to/repo.git", "#{protocol}test.com/path/to/repo.git"],
+ ["#{protocol}user@test.com", "#{protocol}*****@test.com"],
+ ["#{protocol}user:pass@test.com", "#{protocol}*****:*****@test.com"],
+ ["#{protocol}user:@test.com", "#{protocol}*****@test.com"],
+ ["#{protocol}:pass@test.com", "#{protocol}:*****@test.com"]
+ ]
+ end
# SCP-style URLs are left unmodified
- 'user@server:project.git' | 'user@server:project.git'
- 'user:pass@server:project.git' | 'user:pass@server:project.git'
+ urls << ['user@server:project.git', 'user@server:project.git']
+ urls << ['user:@server:project.git', 'user:@server:project.git']
+ urls << [':pass@server:project.git', ':pass@server:project.git']
+ urls << ['user:pass@server:project.git', 'user:pass@server:project.git']
# return an empty string for invalid URLs
- 'ssh://' | ''
+ urls << ['ssh://', '']
end
with_them do
- it { expect(sanitize_url(input)).to include("repository '#{output}' not found") }
+ it { expect(sanitize_url(input)).to include("repository `#{output}` not found") }
end
end
diff --git a/spec/lib/rouge/formatters/html_gitlab_spec.rb b/spec/lib/rouge/formatters/html_gitlab_spec.rb
index 79bfdb262c..6fc1b395fc 100644
--- a/spec/lib/rouge/formatters/html_gitlab_spec.rb
+++ b/spec/lib/rouge/formatters/html_gitlab_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Rouge::Formatters::HTMLGitlab do
+RSpec.describe Rouge::Formatters::HTMLGitlab, feature_category: :source_code_management do
describe '#format' do
subject { described_class.format(tokens, **options) }
@@ -67,5 +67,24 @@ RSpec.describe Rouge::Formatters::HTMLGitlab do
is_expected.to include(%{}).exactly(4).times
end
end
+
+ context 'when space characters and zero-width spaces are used' do
+ let(:lang) { 'ruby' }
+ let(:tokens) { lexer.lex(code, continue: false) }
+
+ let(:code) do
+ <<~JS
+ def\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000hello
+ JS
+ end
+
+ it 'replaces the space characters with spaces' do
+ is_expected.to eq(
+ "" \
+ "def hello" \
+ ""
+ )
+ end
+ end
end
end
diff --git a/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb b/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb
new file mode 100644
index 0000000000..37bff128ed
--- /dev/null
+++ b/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe FinalizeBackfillUserDetailsFields, :migration, feature_category: :user_management do
+ let(:batched_migrations) { table(:batched_background_migrations) }
+ let(:batch_failed_status) { 2 }
+ let(:batch_finalized_status) { 3 }
+
+ let!(:migration) { described_class::BACKFILL_MIGRATION }
+
+ describe '#up' do
+ shared_examples 'finalizes the migration' do
+ it 'finalizes the migration' do
+ expect do
+ migrate!
+
+ migration_record.reload
+ failed_job.reload
+ end.to change { migration_record.status }.from(migration_record.status).to(3).and(
+ change { failed_job.status }.from(batch_failed_status).to(batch_finalized_status)
+ )
+ end
+ end
+
+ context 'when migration is missing' do
+ it 'warns migration not found' do
+ expect(Gitlab::AppLogger)
+ .to receive(:warn).with(/Could not find batched background migration for the given configuration:/)
+
+ migrate!
+ end
+ end
+
+ context 'with migration present' do
+ let!(:migration_record) do
+ batched_migrations.create!(
+ job_class_name: migration,
+ table_name: :users,
+ column_name: :id,
+ job_arguments: [],
+ interval: 2.minutes,
+ min_value: 1,
+ max_value: 2,
+ batch_size: 1000,
+ sub_batch_size: 500,
+ max_batch_size: 5000,
+ gitlab_schema: :gitlab_main,
+ status: 3 # finished
+ )
+ end
+
+ context 'when migration finished successfully' do
+ it 'does not raise exception' do
+ expect { migrate! }.not_to raise_error
+ end
+ end
+
+ context 'when users.linkedin column has already been dropped' do
+ before do
+ table(:users).create!(id: 1, email: 'author@example.com', username: 'author', projects_limit: 10)
+ ActiveRecord::Base.connection.execute("ALTER TABLE users DROP COLUMN linkedin")
+ migration_record.update_column(:status, 1)
+ end
+
+ after do
+ ActiveRecord::Base.connection.execute("ALTER TABLE users ADD COLUMN linkedin text DEFAULT '' NOT NULL")
+ end
+
+ it 'does not raise exception' do
+ expect { migrate! }.not_to raise_error
+ end
+ end
+
+ context 'with different migration statuses', :redis do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status, :description) do
+ 0 | 'paused'
+ 1 | 'active'
+ 4 | 'failed'
+ 5 | 'finalizing'
+ end
+
+ with_them do
+ let!(:failed_job) do
+ table(:batched_background_migration_jobs).create!(
+ batched_background_migration_id: migration_record.id,
+ status: batch_failed_status,
+ min_value: 1,
+ max_value: 10,
+ attempts: 2,
+ batch_size: 100,
+ sub_batch_size: 10
+ )
+ end
+
+ before do
+ migration_record.update!(status: status)
+ end
+
+ it_behaves_like 'finalizes the migration'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/nullify_last_error_from_project_mirror_data_spec.rb b/spec/migrations/nullify_last_error_from_project_mirror_data_spec.rb
new file mode 100644
index 0000000000..6c5679b674
--- /dev/null
+++ b/spec/migrations/nullify_last_error_from_project_mirror_data_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe NullifyLastErrorFromProjectMirrorData, feature_category: :source_code_management do
+ let(:migration) { described_class::MIGRATION }
+
+ before do
+ migrate!
+ end
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of projects' do
+ expect(migration).to(
+ have_scheduled_batched_migration(
+ table_name: :project_mirror_data,
+ column_name: :id,
+ interval: described_class::INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE
+ )
+ )
+ end
+ end
+
+ describe '#down' do
+ before do
+ schema_migrate_down!
+ end
+
+ it 'deletes all batched migration records' do
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/models/concerns/taskable_spec.rb b/spec/models/concerns/taskable_spec.rb
index 0ad29454ff..20de8995d1 100644
--- a/spec/models/concerns/taskable_spec.rb
+++ b/spec/models/concerns/taskable_spec.rb
@@ -35,17 +35,29 @@ RSpec.describe Taskable, feature_category: :team_planning do
TaskList::Item.new('- [ ]', 'First item'),
TaskList::Item.new('- [x]', 'Second item'),
TaskList::Item.new('* [x]', 'First item'),
- TaskList::Item.new('* [ ]', 'Second item'),
- TaskList::Item.new('+ [ ]', 'No-break space (U+00A0)'),
- TaskList::Item.new('+ [ ]', 'Figure space (U+2007)'),
- TaskList::Item.new('+ [ ]', 'Narrow no-break space (U+202F)'),
- TaskList::Item.new('+ [ ]', 'Thin space (U+2009)')
+ TaskList::Item.new('* [ ]', 'Second item')
]
end
subject { described_class.get_tasks(description) }
it { is_expected.to match(expected_result) }
+
+ describe 'with single line comments' do
+ let(:description) do
+ <<~MARKDOWN
+
+
+ - [ ] only task item
+
+
+ MARKDOWN
+ end
+
+ let(:expected_result) { [TaskList::Item.new('- [ ]', 'only task item')] }
+
+ it { is_expected.to match(expected_result) }
+ end
end
describe '#task_list_items' do
diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
index d48f6f7f3e..26795c0ea7 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -302,7 +302,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
it { expect(result[:issue].gitlab_commit_path).to eq(nil) }
end
- context 'when repo commit matches first relase version' do
+ context 'when repo commit matches first release version' do
let(:commit) { instance_double(Commit, id: commit_id) }
let(:repository) { instance_double(Repository, commit: commit) }
diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb
index 72958a54e1..ad5f01fe05 100644
--- a/spec/models/hooks/web_hook_spec.rb
+++ b/spec/models/hooks/web_hook_spec.rb
@@ -242,6 +242,22 @@ RSpec.describe WebHook, feature_category: :integrations do
expect(hook.url_variables).to eq({})
end
+ it 'resets url variables if url is changed and url variables are appended' do
+ hook.url = 'http://suspicious.example.com/{abc}/{foo}'
+ hook.url_variables = hook.url_variables.merge('foo' => 'bar')
+
+ expect(hook).not_to be_valid
+ expect(hook.url_variables).to eq({})
+ end
+
+ it 'resets url variables if url is changed and url variables are removed' do
+ hook.url = 'http://suspicious.example.com/{abc}'
+ hook.url_variables = hook.url_variables.except("def")
+
+ expect(hook).not_to be_valid
+ expect(hook.url_variables).to eq({})
+ end
+
it 'does not reset url variables if both url and url variables are changed' do
hook.url = 'http://example.com/{one}/{two}'
hook.url_variables = { 'one' => 'foo', 'two' => 'bar' }
@@ -249,6 +265,18 @@ RSpec.describe WebHook, feature_category: :integrations do
expect(hook).to be_valid
expect(hook.url_variables).to eq({ 'one' => 'foo', 'two' => 'bar' })
end
+
+ context 'without url variables' do
+ subject(:hook) { build_stubbed(:project_hook, project: project, url: 'http://example.com') }
+
+ it 'does not reset url variables' do
+ hook.url = 'http://example.com/{one}/{two}'
+ hook.url_variables = { 'one' => 'foo', 'two' => 'bar' }
+
+ expect(hook).to be_valid
+ expect(hook.url_variables).to eq({ 'one' => 'foo', 'two' => 'bar' })
+ end
+ end
end
it "only consider these branch filter strategies are valid" do
diff --git a/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb b/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb
index de10653d87..a2ab59f56a 100644
--- a/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb
+++ b/spec/models/preloaders/user_max_access_level_in_projects_preloader_spec.rb
@@ -23,8 +23,7 @@ RSpec.describe Preloaders::UserMaxAccessLevelInProjectsPreloader do
# we have an existing N+1, one for each project for which user is not a member
# in this spec, project_3, project_4, project_5
# https://gitlab.com/gitlab-org/gitlab/-/issues/362890
- ee_only_policy_check_queries = Gitlab.ee? ? 1 : 0
- expect { query }.to make_queries(projects.size + 3 + ee_only_policy_check_queries)
+ expect { query }.to make_queries(projects.size + 3)
end
end
diff --git a/spec/models/uploads/fog_spec.rb b/spec/models/uploads/fog_spec.rb
index 1ffe7c6c43..a1b0bcf95e 100644
--- a/spec/models/uploads/fog_spec.rb
+++ b/spec/models/uploads/fog_spec.rb
@@ -3,10 +3,21 @@
require 'spec_helper'
RSpec.describe Uploads::Fog do
+ let(:credentials) do
+ {
+ provider: "AWS",
+ aws_access_key_id: "AWS_ACCESS_KEY_ID",
+ aws_secret_access_key: "AWS_SECRET_ACCESS_KEY",
+ region: "eu-central-1"
+ }
+ end
+
+ let(:bucket_prefix) { nil }
let(:data_store) { described_class.new }
+ let(:config) { { connection: credentials, bucket_prefix: bucket_prefix, remote_directory: 'uploads' } }
before do
- stub_uploads_object_storage(FileUploader)
+ stub_uploads_object_storage(FileUploader, config: config)
end
describe '#available?' do
@@ -18,7 +29,7 @@ RSpec.describe Uploads::Fog do
context 'when object storage is disabled' do
before do
- stub_uploads_object_storage(FileUploader, enabled: false)
+ stub_uploads_object_storage(FileUploader, config: config, enabled: false)
end
it { is_expected.to be_falsy }
@@ -28,6 +39,60 @@ RSpec.describe Uploads::Fog do
context 'model with uploads' do
let(:project) { create(:project) }
let(:relation) { project.uploads }
+ let(:connection) { ::Fog::Storage.new(credentials) }
+ let(:paths) { relation.pluck(:path) }
+
+ # Only fog-aws simulates mocking of deleting an object properly.
+ # We'll just test that the various providers implement the require methods.
+ describe 'Fog provider acceptance tests' do
+ let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) }
+
+ shared_examples 'Fog provider' do
+ describe '#get_object' do
+ it 'returns a Hash with a body' do
+ expect(connection.get_object('uploads', paths.first)[:body]).not_to be_nil
+ end
+ end
+
+ describe '#delete_object' do
+ it 'returns true' do
+ expect(connection.delete_object('uploads', paths.first)).to be_truthy
+ end
+ end
+ end
+
+ before do
+ uploads.each { |upload| upload.retrieve_uploader.migrate!(2) }
+ end
+
+ context 'with AWS provider' do
+ it_behaves_like 'Fog provider'
+ end
+
+ context 'with Google provider' do
+ let(:credentials) do
+ {
+ provider: "Google",
+ google_storage_access_key_id: 'ACCESS_KEY_ID',
+ google_storage_secret_access_key: 'SECRET_ACCESS_KEY'
+ }
+ end
+
+ it_behaves_like 'Fog provider'
+ end
+
+ context 'with AzureRM provider' do
+ let(:credentials) do
+ {
+ provider: 'AzureRM',
+ azure_storage_account_name: 'test-access-id',
+ azure_storage_access_key: 'secret'
+ }
+ end
+
+ it_behaves_like 'Fog provider'
+ end
+ end
describe '#keys' do
let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: project) }
@@ -40,7 +105,7 @@ RSpec.describe Uploads::Fog do
end
describe '#delete_keys' do
- let(:connection) { ::Fog::Storage.new(FileUploader.object_store_credentials) }
+ let(:connection) { ::Fog::Storage.new(credentials) }
let(:keys) { data_store.keys(relation) }
let(:paths) { relation.pluck(:path) }
let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) }
@@ -63,6 +128,22 @@ RSpec.describe Uploads::Fog do
end
end
+ context 'with bucket prefix' do
+ let(:bucket_prefix) { 'test-prefix' }
+
+ it 'deletes multiple data' do
+ paths.each do |path|
+ expect(connection.get_object('uploads', File.join(bucket_prefix, path))[:body]).not_to be_nil
+ end
+
+ subject
+
+ paths.each do |path|
+ expect { connection.get_object('uploads', File.join(bucket_prefix, path))[:body] }.to raise_error(Excon::Error::NotFound)
+ end
+ end
+ end
+
context 'when one of keys is missing' do
let(:keys) { ['unknown'] + super() }
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index b2fb310aca..0c359b80fb 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -697,6 +697,39 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
end
end
+ describe 'read_prometheus', feature_category: :metrics do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ project.project_feature.update!(metrics_dashboard_access_level: ProjectFeature::ENABLED)
+ end
+
+ let(:policy) { :read_prometheus }
+
+ where(:project_visibility, :role, :allowed) do
+ :public | :anonymous | false
+ :public | :guest | false
+ :public | :reporter | true
+ :internal | :anonymous | false
+ :internal | :guest | false
+ :internal | :reporter | true
+ :private | :anonymous | false
+ :private | :guest | false
+ :private | :reporter | true
+ end
+
+ with_them do
+ let(:current_user) { public_send(role) }
+ let(:project) { public_send("#{project_visibility}_project") }
+
+ if params[:allowed]
+ it { is_expected.to be_allowed(policy) }
+ else
+ it { is_expected.not_to be_allowed(policy) }
+ end
+ end
+ end
+
describe 'update_max_artifacts_size' do
context 'when no user' do
let(:current_user) { anonymous }
@@ -972,7 +1005,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { guest }
it { is_expected.to be_allowed(:metrics_dashboard) }
- it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
it { is_expected.to be_allowed(:read_deployment) }
it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
@@ -982,7 +1015,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { anonymous }
it { is_expected.to be_allowed(:metrics_dashboard) }
- it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
it { is_expected.to be_allowed(:read_deployment) }
it { is_expected.to be_disallowed(:read_metrics_user_starred_dashboard) }
it { is_expected.to be_disallowed(:create_metrics_user_starred_dashboard) }
@@ -1008,12 +1041,14 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { guest }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
context 'with anonymous' do
let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
end
@@ -1036,7 +1071,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { guest }
it { is_expected.to be_allowed(:metrics_dashboard) }
- it { is_expected.to be_allowed(:read_prometheus) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
it { is_expected.to be_allowed(:read_deployment) }
it { is_expected.to be_allowed(:read_metrics_user_starred_dashboard) }
it { is_expected.to be_allowed(:create_metrics_user_starred_dashboard) }
@@ -1046,6 +1081,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
end
end
@@ -1068,12 +1104,14 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { guest }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
context 'with anonymous' do
let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
end
@@ -1092,12 +1130,14 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
let(:current_user) { guest }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
context 'with anonymous' do
let(:current_user) { anonymous }
it { is_expected.to be_disallowed(:metrics_dashboard) }
+ it { is_expected.to be_disallowed(:read_prometheus) }
end
end
end
@@ -2016,7 +2056,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
:public | ProjectFeature::ENABLED | :anonymous | true
:public | ProjectFeature::PRIVATE | :maintainer | true
:public | ProjectFeature::PRIVATE | :developer | true
- :public | ProjectFeature::PRIVATE | :guest | true
+ :public | ProjectFeature::PRIVATE | :guest | false
:public | ProjectFeature::PRIVATE | :anonymous | false
:public | ProjectFeature::DISABLED | :maintainer | false
:public | ProjectFeature::DISABLED | :developer | false
@@ -2028,7 +2068,7 @@ RSpec.describe ProjectPolicy, feature_category: :authentication_and_authorizatio
:internal | ProjectFeature::ENABLED | :anonymous | false
:internal | ProjectFeature::PRIVATE | :maintainer | true
:internal | ProjectFeature::PRIVATE | :developer | true
- :internal | ProjectFeature::PRIVATE | :guest | true
+ :internal | ProjectFeature::PRIVATE | :guest | false
:internal | ProjectFeature::PRIVATE | :anonymous | false
:internal | ProjectFeature::DISABLED | :maintainer | false
:internal | ProjectFeature::DISABLED | :developer | false
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index 555ba2bc97..be26fe2406 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -573,6 +573,22 @@ RSpec.describe API::Repositories, feature_category: :source_code_management do
context 'when authenticated', 'as a developer' do
it_behaves_like 'repository compare' do
let(:current_user) { user }
+
+ context 'when user does not have read access to the parent project' do
+ let_it_be(:group) { create(:group) }
+ let(:forked_project) { fork_project(project, current_user, repository: true, namespace: group) }
+
+ before do
+ forked_project.add_guest(current_user)
+ end
+
+ it 'returns 403 error' do
+ get api(route, current_user), params: { from: 'improve/awesome', to: 'feature', from_project_id: forked_project.id }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq("403 Forbidden - You don't have access to this fork's parent project")
+ end
+ end
end
end
diff --git a/spec/services/ci/retry_job_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb
index 10acf032b1..fed66bc535 100644
--- a/spec/services/ci/retry_job_service_spec.rb
+++ b/spec/services/ci/retry_job_service_spec.rb
@@ -356,6 +356,21 @@ RSpec.describe Ci::RetryJobService, feature_category: :continuous_integration do
it_behaves_like 'retries the job'
+ context 'automatic retryable build' do
+ let!(:auto_retryable_build) do
+ create(:ci_build, pipeline: pipeline, ci_stage: stage, user: user, options: { retry: 1 })
+ end
+
+ def drop_build!
+ auto_retryable_build.drop_with_exit_code!('test failure', 1)
+ end
+
+ it 'creates a new build and enqueues BuildQueueWorker' do
+ expect { drop_build! }.to change { Ci::Build.count }.by(1)
+ .and change { BuildQueueWorker.jobs.count }.by(1)
+ end
+ end
+
context 'when there are subsequent jobs that are skipped' do
let!(:subsequent_build) do
create(:ci_build, :skipped, pipeline: pipeline, ci_stage: deploy_stage)
diff --git a/spec/services/ci/runners/set_runner_associated_projects_service_spec.rb b/spec/services/ci/runners/set_runner_associated_projects_service_spec.rb
index 9921f9322b..d952fca25a 100644
--- a/spec/services/ci/runners/set_runner_associated_projects_service_spec.rb
+++ b/spec/services/ci/runners/set_runner_associated_projects_service_spec.rb
@@ -3,17 +3,19 @@
require 'spec_helper'
RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', feature_category: :runner_fleet do
- subject(:execute) { described_class.new(runner: runner, current_user: user, project_ids: project_ids).execute }
+ subject(:execute) do
+ described_class.new(runner: runner, current_user: user, project_ids: new_projects.map(&:id)).execute
+ end
let_it_be(:owner_project) { create(:project) }
let_it_be(:project2) { create(:project) }
- let_it_be(:original_projects) { [owner_project, project2] }
+ let(:original_projects) { [owner_project, project2] }
let(:runner) { create(:ci_runner, :project, projects: original_projects) }
context 'without user' do
let(:user) { nil }
- let(:project_ids) { [project2.id] }
+ let(:new_projects) { [project2] }
it 'does not call assign_to on runner and returns error response', :aggregate_failures do
expect(runner).not_to receive(:assign_to)
@@ -24,8 +26,8 @@ RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', fe
end
context 'with unauthorized user' do
- let(:user) { build(:user) }
- let(:project_ids) { [project2.id] }
+ let(:user) { create(:user) }
+ let(:new_projects) { [project2] }
it 'does not call assign_to on runner and returns error message' do
expect(runner).not_to receive(:assign_to)
@@ -35,15 +37,19 @@ RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', fe
end
end
- context 'with admin user', :enable_admin_mode do
- let_it_be(:user) { create(:user, :admin) }
+ context 'with authorized user' do
+ let_it_be(:project3) { create(:project) }
+ let_it_be(:project4) { create(:project) }
- let(:project3) { create(:project) }
- let(:project4) { create(:project) }
+ let(:projects_with_maintainer_access) { original_projects }
- context 'with successful requests' do
+ before do
+ projects_with_maintainer_access.each { |project| project.add_maintainer(user) }
+ end
+
+ shared_context 'with successful requests' do
context 'when disassociating a project' do
- let(:project_ids) { [project3.id, project4.id] }
+ let(:new_projects) { [project3, project4] }
it 'reassigns associated projects and returns success response' do
expect(execute).to be_success
@@ -51,12 +57,12 @@ RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', fe
runner.reload
expect(runner.owner_project).to eq(owner_project)
- expect(runner.projects.ids).to match_array([owner_project.id] + project_ids)
+ expect(runner.projects.ids).to match_array([owner_project.id] + new_projects.map(&:id))
end
end
context 'when disassociating no projects' do
- let(:project_ids) { [project2.id, project3.id] }
+ let(:new_projects) { [project2, project3] }
it 'reassigns associated projects and returns success response' do
expect(execute).to be_success
@@ -64,12 +70,12 @@ RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', fe
runner.reload
expect(runner.owner_project).to eq(owner_project)
- expect(runner.projects.ids).to match_array([owner_project.id] + project_ids)
+ expect(runner.projects.ids).to match_array([owner_project.id] + new_projects.map(&:id))
end
end
context 'when disassociating all projects' do
- let(:project_ids) { [] }
+ let(:new_projects) { [] }
it 'reassigns associated projects and returns success response' do
expect(execute).to be_success
@@ -82,19 +88,8 @@ RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', fe
end
end
- context 'with failing assign_to requests' do
- let(:project_ids) { [project3.id, project4.id] }
-
- it 'returns error response and rolls back transaction' do
- expect(runner).to receive(:assign_to).with(project4, user).once.and_return(false)
-
- expect(execute).to be_error
- expect(runner.reload.projects).to eq(original_projects)
- end
- end
-
- context 'with failing destroy calls' do
- let(:project_ids) { [project3.id, project4.id] }
+ shared_context 'with failing destroy calls' do
+ let(:new_projects) { [project3, project4] }
it 'returns error response and rolls back transaction' do
allow_next_found_instance_of(Ci::RunnerProject) do |runner_project|
@@ -105,5 +100,35 @@ RSpec.describe ::Ci::Runners::SetRunnerAssociatedProjectsService, '#execute', fe
expect(runner.reload.projects).to eq(original_projects)
end
end
+
+ context 'with maintainer user' do
+ let(:user) { create(:user) }
+ let(:projects_with_maintainer_access) { original_projects + new_projects }
+
+ it_behaves_like 'with successful requests'
+ it_behaves_like 'with failing destroy calls'
+
+ context 'when associating new projects' do
+ let(:new_projects) { [project3, project4] }
+
+ context 'with missing permissions on one of the new projects' do
+ let(:projects_with_maintainer_access) { original_projects + [project3] }
+
+ it 'returns error response and rolls back transaction' do
+ expect(execute).to be_error
+ expect(execute.errors).to contain_exactly('user is not authorized to add runners to project')
+ expect(runner.reload.projects).to eq(original_projects)
+ end
+ end
+ end
+ end
+
+ context 'with admin user', :enable_admin_mode do
+ let(:user) { create(:user, :admin) }
+ let(:projects_with_maintainer_access) { original_projects + new_projects }
+
+ it_behaves_like 'with successful requests'
+ it_behaves_like 'with failing destroy calls'
+ end
end
end
diff --git a/spec/services/merge_requests/push_options_handler_service_spec.rb b/spec/services/merge_requests/push_options_handler_service_spec.rb
index 251bf6f0d9..03f3d56cdd 100644
--- a/spec/services/merge_requests/push_options_handler_service_spec.rb
+++ b/spec/services/merge_requests/push_options_handler_service_spec.rb
@@ -861,6 +861,21 @@ RSpec.describe MergeRequests::PushOptionsHandlerService do
end
end
+ describe 'when user does not have access to target project' do
+ let(:push_options) { { create: true, target: 'my-branch' } }
+ let(:changes) { default_branch_changes }
+
+ before do
+ allow(user1).to receive(:can?).with(:read_code, project).and_return(false)
+ end
+
+ it 'records an error', :sidekiq_inline do
+ service.execute
+
+ expect(service.errors).to eq(["User access was denied"])
+ end
+ end
+
describe 'when MRs are not enabled' do
let(:project) { create(:project, :public, :repository).tap { |pr| pr.add_developer(user1) } }
let(:push_options) { { create: true } }
diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb
index 5736bf885b..b4250fcf04 100644
--- a/spec/services/web_hook_service_spec.rb
+++ b/spec/services/web_hook_service_spec.rb
@@ -130,8 +130,8 @@ RSpec.describe WebHookService, :request_store, :clean_gitlab_redis_shared_state,
context 'there is userinfo' do
before do
project_hook.update!(
- url: 'http://{one}:{two}@example.com',
- url_variables: { 'one' => 'a', 'two' => 'b' }
+ url: 'http://{foo}:{bar}@example.com',
+ url_variables: { 'foo' => 'a', 'bar' => 'b' }
)
stub_full_request('http://example.com', method: :post)
end
diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb
index c163ce1d88..6b63385622 100644
--- a/spec/support/helpers/stub_object_storage.rb
+++ b/spec/support/helpers/stub_object_storage.rb
@@ -15,7 +15,7 @@ module StubObjectStorage
direct_upload: false,
cdn: {}
)
-
+ old_config = Settingslogic.new(config.deep_stringify_keys)
new_config = config.to_h.deep_symbolize_keys.merge({
enabled: enabled,
proxy_download: proxy_download,
@@ -37,7 +37,7 @@ module StubObjectStorage
return unless enabled
stub_object_storage(connection_params: uploader.object_store_credentials,
- remote_directory: config.remote_directory)
+ remote_directory: old_config.remote_directory)
end
def stub_object_storage(connection_params:, remote_directory:)
diff --git a/spec/tooling/danger/stable_branch_spec.rb b/spec/tooling/danger/stable_branch_spec.rb
index 9eee077d49..4d86e066c2 100644
--- a/spec/tooling/danger/stable_branch_spec.rb
+++ b/spec/tooling/danger/stable_branch_spec.rb
@@ -92,11 +92,20 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
let(:pipeline_bridges_response) do
[
- { 'name' => 'e2e:package-and-test',
- 'status' => 'success' }
+ {
+ 'name' => 'e2e:package-and-test',
+ 'status' => pipeline_bridge_state,
+ 'downstream_pipeline' => {
+ 'id' => '123',
+ 'status' => package_and_qa_state
+ }
+ }
]
end
+ let(:pipeline_bridge_state) { 'running' }
+ let(:package_and_qa_state) { 'success' }
+
let(:parsed_response) do
[
{ 'version' => '15.1.1' },
@@ -154,6 +163,13 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
it_behaves_like 'with a failure', described_class::BUG_ERROR_MESSAGE
end
+ context 'with only documentation changes and no bug label' do
+ let(:bug_label_present) { false }
+ let(:changes_by_category_response) { { docs: ['foo.md'] } }
+
+ it_behaves_like 'without a failure'
+ end
+
context 'with a pipeline:expedite label' do
let(:pipeline_expedite_label_present) { true }
@@ -161,39 +177,58 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
it_behaves_like 'bypassing when flaky test or docs only'
end
- context 'when no package-and-test job is found' do
+ context 'when no package-and-test bridge is found' do
let(:pipeline_bridges_response) { nil }
it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE
it_behaves_like 'bypassing when flaky test or docs only'
end
- context 'when package-and-test job is in manual state' do
- described_class::FAILING_PACKAGE_AND_TEST_STATUSES.each do |status|
- let(:pipeline_bridges_response) do
- [
- { 'name' => 'e2e:package-and-test',
- 'status' => status }
- ]
- end
+ context 'when package-and-test bridge is created' do
+ let(:pipeline_bridge_state) { 'created' }
- it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE
- it_behaves_like 'bypassing when flaky test or docs only'
- end
+ it_behaves_like 'with a warning', described_class::WARN_PACKAGE_AND_TEST_MESSAGE
+ it_behaves_like 'bypassing when flaky test or docs only'
end
- context 'when package-and-test job is in a non-successful state' do
+ context 'when package-and-test bridge has been canceled and no downstream pipeline is generated' do
+ let(:pipeline_bridge_state) { 'canceled' }
+
let(:pipeline_bridges_response) do
[
- { 'name' => 'e2e:package-and-test',
- 'status' => 'running' }
+ {
+ 'name' => 'e2e:package-and-test',
+ 'status' => pipeline_bridge_state,
+ 'downstream_pipeline' => nil
+ }
]
end
+ it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE
+ it_behaves_like 'bypassing when flaky test or docs only'
+ end
+
+ context 'when package-and-test job is in a non-successful state' do
+ let(:package_and_qa_state) { 'running' }
+
it_behaves_like 'with a warning', described_class::WARN_PACKAGE_AND_TEST_MESSAGE
it_behaves_like 'bypassing when flaky test or docs only'
end
+ context 'when package-and-test job is in manual state' do
+ let(:package_and_qa_state) { 'manual' }
+
+ it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE
+ it_behaves_like 'bypassing when flaky test or docs only'
+ end
+
+ context 'when package-and-test job is canceled' do
+ let(:package_and_qa_state) { 'canceled' }
+
+ it_behaves_like 'with a failure', described_class::NEEDS_PACKAGE_AND_TEST_MESSAGE
+ it_behaves_like 'bypassing when flaky test or docs only'
+ end
+
context 'when no pipeline is found' do
before do
allow(gitlab_gem_client).to receive(:mr_json).and_return({})
@@ -266,23 +301,54 @@ RSpec.describe Tooling::Danger::StableBranch, feature_category: :delivery do
end
end
- describe '#non_security_stable_branch?' do
- subject { stable_branch.non_security_stable_branch? }
+ describe '#encourage_package_and_qa_execution?' do
+ subject { stable_branch.encourage_package_and_qa_execution? }
- where(:stable_branch?, :security_mr?, :expected_result) do
- true | true | false
- false | true | false
- true | false | true
- false | false | false
+ where(:stable_branch?, :security_mr?, :documentation?, :flaky?, :result) do
+ # security merge requests
+ true | true | true | true | false
+ true | true | true | false | false
+ true | true | false | true | false
+ true | true | false | false | false
+ # canonical merge requests with doc and flaky changes only
+ true | false | true | true | false
+ true | false | true | false | false
+ true | false | false | true | false
+ # canonical merge requests with app code
+ true | false | false | false | true
end
with_them do
before do
- allow(fake_helper).to receive(:mr_target_branch).and_return(stable_branch? ? '15-1-stable-ee' : 'main')
- allow(fake_helper).to receive(:security_mr?).and_return(security_mr?)
+ allow(fake_helper)
+ .to receive(:mr_target_branch)
+ .and_return(stable_branch? ? '15-1-stable-ee' : 'main')
+
+ allow(fake_helper)
+ .to receive(:security_mr?)
+ .and_return(security_mr?)
+
+ allow(fake_helper)
+ .to receive(:has_only_documentation_changes?)
+ .and_return(documentation?)
+
+ changes_by_category =
+ if documentation?
+ { docs: ['foo.md'] }
+ else
+ { graphql: ['bar.rb'] }
+ end
+
+ allow(fake_helper)
+ .to receive(:changes_by_category)
+ .and_return(changes_by_category)
+
+ allow(fake_helper)
+ .to receive(:mr_has_labels?)
+ .and_return(flaky?)
end
- it { is_expected.to eq(expected_result) }
+ it { is_expected.to eq(result) }
end
end
end
diff --git a/spec/views/explore/projects/page_out_of_bounds.html.haml_spec.rb b/spec/views/explore/projects/page_out_of_bounds.html.haml_spec.rb
new file mode 100644
index 0000000000..1ace28be5b
--- /dev/null
+++ b/spec/views/explore/projects/page_out_of_bounds.html.haml_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'explore/projects/page_out_of_bounds.html.haml', feature_category: :projects do
+ let(:page_limit) { 10 }
+ let(:unsafe_param) { 'hacked_using_unsafe_param!' }
+
+ before do
+ assign(:max_page_number, page_limit)
+
+ controller.params[:action] = 'index'
+ controller.params[:host] = unsafe_param
+ controller.params[:protocol] = unsafe_param
+ controller.params[:sort] = 'name_asc'
+ end
+
+ it 'removes unsafe params from the link' do
+ render
+
+ href = "/explore/projects?page=#{page_limit}&sort=name_asc"
+ button_text = format(_("Back to page %{number}"), number: page_limit)
+ expect(rendered).to have_link(button_text, href: href)
+ expect(rendered).not_to include(unsafe_param)
+ end
+end
diff --git a/tooling/danger/stable_branch.rb b/tooling/danger/stable_branch.rb
index 8fac1cc5fb..9b46714609 100644
--- a/tooling/danger/stable_branch.rb
+++ b/tooling/danger/stable_branch.rb
@@ -46,21 +46,22 @@ module Tooling
MSG
NEEDS_PACKAGE_AND_TEST_MESSAGE = <<~MSG
- The `e2e:package-and-test` job is not present or needs to be triggered manually. Please start the `e2e:package-and-test`
- job and re-run `danger-review`.
+ The `e2e:package-and-test` job is not present, has been canceled, or needs to be automatically triggered.
+ Please ensure the job is present in the latest pipeline, if necessary, retry the `danger-review` job.
+ Read the "QA e2e:package-and-test" section for more details.
MSG
WARN_PACKAGE_AND_TEST_MESSAGE = <<~MSG
- The `e2e:package-and-test` job needs to succeed or have approval from a Software Engineer in Test. See the section below
- for more details.
+ **The `e2e:package-and-test` job needs to succeed or have approval from a Software Engineer in Test.**
+ Read the "QA e2e:package-and-test" section for more details.
MSG
# rubocop:disable Style/SignalException
def check!
- return unless non_security_stable_branch?
+ return unless valid_stable_branch?
fail FEATURE_ERROR_MESSAGE if has_feature_label?
- fail BUG_ERROR_MESSAGE unless has_bug_label?
+ fail BUG_ERROR_MESSAGE unless bug_fixes_only?
warn VERSION_WARNING_MESSAGE unless targeting_patchable_version?
@@ -68,7 +69,7 @@ module Tooling
fail PIPELINE_EXPEDITE_ERROR_MESSAGE if has_pipeline_expedite_label?
- status = package_and_test_status
+ status = package_and_test_bridge_and_pipeline_status
if status.nil? || FAILING_PACKAGE_AND_TEST_STATUSES.include?(status) # rubocop:disable Style/GuardClause
fail NEEDS_PACKAGE_AND_TEST_MESSAGE
@@ -78,22 +79,38 @@ module Tooling
end
# rubocop:enable Style/SignalException
- def non_security_stable_branch?
- !!stable_target_branch && !helper.security_mr?
+ def encourage_package_and_qa_execution?
+ valid_stable_branch? &&
+ !has_only_documentation_changes? &&
+ !has_flaky_failure_label?
end
private
- def package_and_test_status
+ def valid_stable_branch?
+ !!stable_target_branch && !helper.security_mr?
+ end
+
+ def package_and_test_bridge_and_pipeline_status
mr_head_pipeline_id = gitlab.mr_json.dig('head_pipeline', 'id')
return unless mr_head_pipeline_id
- pipeline_bridges = gitlab.api.pipeline_bridges(helper.mr_target_project_id, mr_head_pipeline_id)
- package_and_test_pipeline = pipeline_bridges&.find { |j| j['name'] == 'e2e:package-and-test' }
+ bridge = package_and_test_bridge(mr_head_pipeline_id)
- return unless package_and_test_pipeline
+ return unless bridge
- package_and_test_pipeline['status']
+ if bridge['status'] == 'created'
+ bridge['status']
+ else
+ bridge.fetch('downstream_pipeline')&.fetch('status')
+ end
+ end
+
+ def package_and_test_bridge(mr_head_pipeline_id)
+ gitlab
+ .api
+ .pipeline_bridges(helper.mr_target_project_id, mr_head_pipeline_id)
+ &.find { |bridge| bridge['name'] == 'e2e:package-and-test' }
end
def stable_target_branch
@@ -116,6 +133,10 @@ module Tooling
helper.mr_has_labels?('failure::flaky-test')
end
+ def bug_fixes_only?
+ has_bug_label? || has_only_documentation_changes?
+ end
+
def has_only_documentation_changes?
categories_changed = helper.changes_by_category.keys
return false unless categories_changed.size == 1
diff --git a/workhorse/internal/headers/content_headers.go b/workhorse/internal/headers/content_headers.go
index 854cc8abdd..54c7c1bdd9 100644
--- a/workhorse/internal/headers/content_headers.go
+++ b/workhorse/internal/headers/content_headers.go
@@ -1,6 +1,7 @@
package headers
import (
+ "mime"
"net/http"
"regexp"
@@ -13,8 +14,9 @@ var (
imageTypeRegex = regexp.MustCompile(`^image/*`)
svgMimeTypeRegex = regexp.MustCompile(`^image/svg\+xml$`)
- textTypeRegex = regexp.MustCompile(`^text/*`)
-
+ textTypeRegex = regexp.MustCompile(`^text/*`)
+ xmlTypeRegex = regexp.MustCompile(`^text/xml`)
+ xhtmlTypeRegex = regexp.MustCompile(`^text/html`)
videoTypeRegex = regexp.MustCompile(`^video/*`)
pdfTypeRegex = regexp.MustCompile(`application\/pdf`)
@@ -26,6 +28,8 @@ var (
// Mime types that can't be inlined. Usually subtypes of main types
var forbiddenInlineTypes = []*regexp.Regexp{svgMimeTypeRegex}
+var htmlRenderingTypes = []*regexp.Regexp{xmlTypeRegex, xhtmlTypeRegex}
+
// Mime types that can be inlined. We can add global types like "image/" or
// specific types like "text/plain". If there is a specific type inside a global
// allowed type that can't be inlined we must add it to the forbiddenInlineTypes var.
@@ -38,12 +42,28 @@ const (
textPlainContentType = "text/plain; charset=utf-8"
attachmentDispositionText = "attachment"
inlineDispositionText = "inline"
+ dummyFilename = "blob"
)
func SafeContentHeaders(data []byte, contentDisposition string) (string, string) {
- contentType := safeContentType(data)
+ detectedContentType := detectContentType(data)
+
+ contentType := safeContentType(detectedContentType)
contentDisposition = safeContentDisposition(contentType, contentDisposition)
+ // Some browsers will render XML inline unless a filename directive is provided with a non-xml file extension
+ // This overrides the filename directive in the case of XML data
+ for _, element := range htmlRenderingTypes {
+ if isType(detectedContentType, element) {
+ disposition, directives, err := mime.ParseMediaType(contentDisposition)
+ if err == nil {
+ directives["filename"] = dummyFilename
+ contentDisposition = mime.FormatMediaType(disposition, directives)
+ break
+ }
+ }
+ }
+
// Set attachments to application/octet-stream since browsers can do
// a better job distinguishing certain types (for example: ZIP files
// vs. Microsoft .docx files). However, browsers may safely render SVGs even
@@ -56,15 +76,17 @@ func SafeContentHeaders(data []byte, contentDisposition string) (string, string)
return contentType, contentDisposition
}
-func safeContentType(data []byte) string {
+func detectContentType(data []byte) string {
// Special case for svg because DetectContentType detects it as text
if svg.Is(data) {
return svgContentType
}
// Override any existing Content-Type header from other ResponseWriters
- contentType := http.DetectContentType(data)
+ return http.DetectContentType(data)
+}
+func safeContentType(contentType string) string {
// http.DetectContentType does not support JavaScript and would only
// return text/plain. But for cautionary measures, just in case they start supporting
// it down the road and start returning application/javascript, we want to handle it now
diff --git a/workhorse/internal/headers/content_headers_test.go b/workhorse/internal/headers/content_headers_test.go
new file mode 100644
index 0000000000..7cfce335d8
--- /dev/null
+++ b/workhorse/internal/headers/content_headers_test.go
@@ -0,0 +1,56 @@
+package headers
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func fileContents(fileName string) []byte {
+ fileContents, _ := os.ReadFile(fileName)
+ return fileContents
+}
+
+func TestHeaders(t *testing.T) {
+ tests := []struct {
+ desc string
+ fileContents []byte
+ expectedContentType string
+ expectedContentDisposition string
+ }{
+ {
+ desc: "XML file",
+ fileContents: fileContents("../../testdata/test.xml"),
+ expectedContentType: "text/plain; charset=utf-8",
+ expectedContentDisposition: "inline; filename=blob",
+ },
+ {
+ desc: "XHTML file",
+ fileContents: fileContents("../../testdata/index.xhtml"),
+ expectedContentType: "text/plain; charset=utf-8",
+ expectedContentDisposition: "inline; filename=blob",
+ },
+ {
+ desc: "svg+xml file",
+ fileContents: fileContents("../../testdata/xml.svg"),
+ expectedContentType: "image/svg+xml",
+ expectedContentDisposition: "attachment",
+ },
+ {
+ desc: "text file",
+ fileContents: []byte(`a text file`),
+ expectedContentType: "text/plain; charset=utf-8",
+ expectedContentDisposition: "inline",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.desc, func(t *testing.T) {
+ contentType, newContentDisposition := SafeContentHeaders(test.fileContents, "")
+
+ require.Equal(t, test.expectedContentType, contentType)
+ require.Equal(t, test.expectedContentDisposition, newContentDisposition)
+ })
+ }
+}
diff --git a/workhorse/internal/senddata/contentprocessor/contentprocessor_test.go b/workhorse/internal/senddata/contentprocessor/contentprocessor_test.go
index b04263de6b..e863935be6 100644
--- a/workhorse/internal/senddata/contentprocessor/contentprocessor_test.go
+++ b/workhorse/internal/senddata/contentprocessor/contentprocessor_test.go
@@ -51,13 +51,13 @@ func TestSetProperContentTypeAndDisposition(t *testing.T) {
{
desc: "HTML type",
contentType: "text/plain; charset=utf-8",
- contentDisposition: "inline",
+ contentDisposition: "inline; filename=blob",
body: "Hello world!",
},
{
desc: "Javascript within HTML type",
contentType: "text/plain; charset=utf-8",
- contentDisposition: "inline",
+ contentDisposition: "inline; filename=blob",
body: "",
},
{
diff --git a/workhorse/testdata/index.xhtml b/workhorse/testdata/index.xhtml
new file mode 100644
index 0000000000..1dd50a70e6
--- /dev/null
+++ b/workhorse/testdata/index.xhtml
@@ -0,0 +1,9 @@
+
+
+
+ Title of document
+
+
+
+
diff --git a/workhorse/testdata/test.xml b/workhorse/testdata/test.xml
new file mode 100644
index 0000000000..54b94e6235
--- /dev/null
+++ b/workhorse/testdata/test.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/workhorse/testdata/xml.svg b/workhorse/testdata/xml.svg
new file mode 100644
index 0000000000..c41c4c44b4
--- /dev/null
+++ b/workhorse/testdata/xml.svg
@@ -0,0 +1,7 @@
+
+
+
+