Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-09-13 00:10:52 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-09-13 00:10:52 +0300
commita60e53c7671c299432f0c255ffaf0e0c9fa9eeab (patch)
tree9682f6acc0c40bd80beb79b9feec645f6252e8e0
parent753eb533e509464184ad267fb894d2c08d0d1ba6 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/gitlab-gems.gitlab-ci.yml3
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.checksum2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/ci/common/pipelines_table.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_table.vue)16
-rw-r--r--app/assets/javascripts/ci/constants.js41
-rw-r--r--app/assets/javascripts/ci/event_hub.js (renamed from app/assets/javascripts/ci/pipeline_details/event_hub.js)0
-rw-r--r--app/assets/javascripts/ci/pipeline_details/constants.js45
-rw-r--r--app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue3
-rw-r--r--app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_details/pipelines_index.js2
-rw-r--r--app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue)2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/utils.js)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/nav_controls.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_labels.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue)2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_url.vue)2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_artifacts.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_status_badge.vue)3
-rw-r--r--app/assets/javascripts/ci/pipelines_page/components/time_ago.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/components/time_ago.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/constants.js2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_actions.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs_count.query.graphql)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/pipelines.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/pipelines.vue)24
-rw-r--r--app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js (renamed from app/assets/javascripts/ci/pipeline_details/services/pipelines_service.js)2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/tokens/constants.js (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/constants.js)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token.vue)5
-rw-r--r--app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token.vue)2
-rw-r--r--app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token.vue)0
-rw-r--r--app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token.vue)5
-rw-r--r--app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue (renamed from app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token.vue)9
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue126
-rw-r--r--app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue14
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue20
-rw-r--r--app/assets/javascripts/super_sidebar/constants.js7
-rw-r--r--app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue2
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss17
-rw-r--r--app/controllers/projects/graphs_controller.rb2
-rw-r--r--app/helpers/registrations_helper.rb2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml10
-rw-r--r--app/views/events/_event.atom.builder2
-rw-r--r--config/initializers/1_settings.rb4
-rw-r--r--doc/administration/settings/security_and_compliance.md4
-rw-r--r--doc/development/gems.md9
-rw-r--r--doc/user/compliance/license_scanning_of_cyclonedx_files/index.md26
-rw-r--r--gems/gem.gitlab-ci.yml2
-rw-r--r--gems/gitlab-http/.gitignore11
-rw-r--r--gems/gitlab-http/.gitlab-ci.yml4
-rw-r--r--gems/gitlab-http/.rspec3
-rw-r--r--gems/gitlab-http/.rubocop.yml56
-rw-r--r--gems/gitlab-http/Gemfile12
-rw-r--r--gems/gitlab-http/Gemfile.lock185
-rw-r--r--gems/gitlab-http/README.md42
-rw-r--r--gems/gitlab-http/gitlab-http.gemspec33
-rw-r--r--gems/gitlab-http/lib/gitlab-http.rb11
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2.rb23
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/buffered_io.rb70
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/client.rb95
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/configuration.rb17
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/domain_allowlist_entry.rb21
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/exceptions.rb24
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/ip_allowlist_entry.rb43
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/net_http_adapter.rb35
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/new_connection_adapter.rb81
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/patches.rb6
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/url_allowlist.rb70
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/url_blocker.rb396
-rw-r--r--gems/gitlab-http/lib/gitlab/http_v2/version.rb9
-rw-r--r--gems/gitlab-http/lib/hostname_override_patch.rb54
-rw-r--r--gems/gitlab-http/lib/httparty/response_patch.rb15
-rw-r--r--gems/gitlab-http/lib/net_http/protocol_patch.rb39
-rw-r--r--gems/gitlab-http/lib/net_http/response_patch.rb48
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/buffered_io_spec.rb50
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/domain_allowlist_entry_spec.rb58
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/http_connection_adapter_spec.rb157
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/ip_allowlist_entry_spec.rb95
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/net_http_adapter_spec.rb23
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb92
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/net_http_response_patch_spec.rb77
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/url_allowlist_spec.rb153
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2/url_blocker_spec.rb956
-rw-r--r--gems/gitlab-http/spec/gitlab/http_v2_spec.rb441
-rw-r--r--gems/gitlab-http/spec/gitlab/stub_requests.rb57
-rw-r--r--gems/gitlab-http/spec/spec_helper.rb50
-rw-r--r--lib/api/entities/personal_access_token.rb2
-rw-r--r--lib/backup/database.rb123
-rw-r--r--lib/backup/database_model.rb80
-rw-r--r--lib/gitlab/http.rb14
-rw-r--r--lib/gitlab/http_connection_adapter.rb2
-rw-r--r--lib/gitlab/import_export/command_line_util.rb2
-rw-r--r--lib/gitlab/workhorse.rb12
-rw-r--r--locale/gitlab.pot15
-rw-r--r--qa/README.md24
-rw-r--r--qa/lib/gitlab/page/main/sign_up.rb13
-rw-r--r--qa/qa/page/project/pipeline/index.rb10
-rw-r--r--qa/qa/page/registration/sign_up.rb24
-rwxr-xr-xscripts/setup-test-env1
-rw-r--r--spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb4
-rw-r--r--spec/features/cycle_analytics_spec.rb4
-rw-r--r--spec/features/groups/dependency_proxy_for_containers_spec.rb12
-rw-r--r--spec/frontend/ci/common/pipelines_table_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_table_spec.js)16
-rw-r--r--spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js2
-rw-r--r--spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js2
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ci_templates_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ios_templates_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state_spec.js)6
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list_spec.js)8
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/mock.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/mock.js)0
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/utils_spec.js)5
-rw-r--r--spec/frontend/ci/pipelines_page/components/nav_controls_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/nav_controls_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_labels_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_operations_spec.js)8
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_triggerer_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_url_spec.js)10
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_artifacts_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search_spec.js)6
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions_spec.js)6
-rw-r--r--spec/frontend/ci/pipelines_page/components/time_ago_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/components/time_ago_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/pipelines_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/pipelines_spec.js)12
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token_spec.js)2
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token_spec.js)4
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js (renamed from spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token_spec.js)4
-rw-r--r--spec/frontend/fixtures/pipelines.rb2
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js213
-rw-r--r--spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js25
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_spec.js37
-rw-r--r--spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js23
-rw-r--r--spec/frontend/super_sidebar/mocks.js24
-rw-r--r--spec/lib/backup/database_model_spec.rb82
-rw-r--r--spec/lib/backup/database_spec.rb86
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb55
-rw-r--r--spec/support/helpers/sign_up_helpers.rb2
-rw-r--r--spec/support/shared_contexts/dependency_proxy_shared_context.rb14
-rw-r--r--spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb6
-rw-r--r--workhorse/internal/dependencyproxy/dependencyproxy.go73
-rw-r--r--workhorse/internal/dependencyproxy/dependencyproxy_test.go153
164 files changed, 4936 insertions, 442 deletions
diff --git a/.gitlab/ci/gitlab-gems.gitlab-ci.yml b/.gitlab/ci/gitlab-gems.gitlab-ci.yml
index 1ee08c4ab85..a773e9c7f90 100644
--- a/.gitlab/ci/gitlab-gems.gitlab-ci.yml
+++ b/.gitlab/ci/gitlab-gems.gitlab-ci.yml
@@ -26,3 +26,6 @@ include:
- local: .gitlab/ci/templates/gem.gitlab-ci.yml
inputs:
gem_name: "csv_builder"
+ - local: .gitlab/ci/templates/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "gitlab-http"
diff --git a/Gemfile b/Gemfile
index 262830ff4b1..a907f122275 100644
--- a/Gemfile
+++ b/Gemfile
@@ -197,7 +197,7 @@ gem 'typhoeus', '~> 1.4.0' # Used with Elasticsearch to support http keep-alive
# Markdown and HTML processing
gem 'html-pipeline', '~> 2.14.3'
-gem 'deckar01-task_list', '2.3.2'
+gem 'deckar01-task_list', '2.3.3'
gem 'gitlab-markup', '~> 1.9.0', require: 'github/markup'
gem 'commonmarker', '~> 0.23.10'
gem 'kramdown', '~> 2.3.1'
diff --git a/Gemfile.checksum b/Gemfile.checksum
index c95a7bca4f7..229972d56f0 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -103,7 +103,7 @@
{"name":"date","version":"3.3.3","platform":"ruby","checksum":"819792019d5712b748fb15f6dfaaedef14b0328723ef23583ea35f186774530f"},
{"name":"dead_end","version":"3.1.1","platform":"ruby","checksum":"1011df7f7c0149be004e11cbbc37747760227c55305cd902fd3c06e1394b2f5b"},
{"name":"debug_inspector","version":"1.1.0","platform":"ruby","checksum":"eaa5a2d0195e1d65fb4164e8e7e466cca2e7eb53bc5e608cf12b8bf02c3a8606"},
-{"name":"deckar01-task_list","version":"2.3.2","platform":"ruby","checksum":"5a19092548d24309d8b2c2704d64cdc08a4a615823c9a722f4142edec1de8805"},
+{"name":"deckar01-task_list","version":"2.3.3","platform":"ruby","checksum":"918abaf3f81e6c0d224c2b7bef593d7f84ee5847a0692726d24e3fb272c2c758"},
{"name":"declarative","version":"0.0.20","platform":"ruby","checksum":"8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9"},
{"name":"declarative_policy","version":"1.1.0","platform":"ruby","checksum":"9af4cf299ade03f2bbf63908f2ce6a117d132fc714c39a128596667fb13331cb"},
{"name":"deprecation_toolkit","version":"1.5.1","platform":"ruby","checksum":"a8a1ab1a19ae40ea12560b65010e099f3459ebde390b76621ef0c21c516a04ba"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 8cbfdae1ae7..a95eeb5b739 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -409,7 +409,7 @@ GEM
date (3.3.3)
dead_end (3.1.1)
debug_inspector (1.1.0)
- deckar01-task_list (2.3.2)
+ deckar01-task_list (2.3.3)
html-pipeline
declarative (0.0.20)
declarative_policy (1.1.0)
@@ -1778,7 +1778,7 @@ DEPENDENCIES
csv_builder!
cvss-suite (~> 3.0.1)
database_cleaner (~> 1.7.0)
- deckar01-task_list (= 2.3.2)
+ deckar01-task_list (= 2.3.3)
declarative_policy (~> 1.1.0)
deprecation_toolkit (~> 1.5.1)
derailed_benchmarks
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_table.vue b/app/assets/javascripts/ci/common/pipelines_table.vue
index f7a620c13a4..807128d2341 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_table.vue
+++ b/app/assets/javascripts/ci/common/pipelines_table.vue
@@ -4,16 +4,16 @@ import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils';
import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
-import PipelineFailedJobsWidget from '~/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
-import eventHub from '../../event_hub';
-import { TRACKING_CATEGORIES } from '../../constants';
-import PipelineOperations from './pipeline_operations.vue';
-import PipelineStopModal from './pipeline_stop_modal.vue';
-import PipelineTriggerer from './pipeline_triggerer.vue';
-import PipelineUrl from './pipeline_url.vue';
-import PipelinesStatusBadge from './pipelines_status_badge.vue';
+import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue';
+import eventHub from '~/ci/event_hub';
+import PipelineOperations from '../pipelines_page/components/pipeline_operations.vue';
+import PipelineStopModal from '../pipelines_page/components/pipeline_stop_modal.vue';
+import PipelineTriggerer from '../pipelines_page/components/pipeline_triggerer.vue';
+import PipelineUrl from '../pipelines_page/components/pipeline_url.vue';
+import PipelinesStatusBadge from '../pipelines_page/components/pipelines_status_badge.vue';
const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!';
const DEFAULT_TH_CLASSES =
diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js
index 7cc41a8731a..93c2504dd5d 100644
--- a/app/assets/javascripts/ci/constants.js
+++ b/app/assets/javascripts/ci/constants.js
@@ -1,12 +1,51 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
+export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs');
+export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline');
+
+export const FILTER_TAG_IDENTIFIER = 'tag';
+
export const JOB_GRAPHQL_ERRORS = {
jobMutationErrorText: __('There was an error running the job. Please try again.'),
jobQueryErrorText: __('There was an error fetching the job.'),
};
+export const ICONS = {
+ TAG: 'tag',
+ MR: 'git-merge',
+ BRANCH: 'branch',
+ RETRY: 'retry',
+ SUCCESS: 'success',
+};
+
export const SUCCESS_STATUS = 'SUCCESS';
export const PASSED_STATUS = 'passed';
export const MANUAL_STATUS = 'manual';
+
+// Constants for the ID and IID selection dropdown
+export const PipelineKeyOptions = [
+ {
+ text: __('Show Pipeline ID'),
+ label: __('Pipeline ID'),
+ value: 'id',
+ },
+ {
+ text: __('Show Pipeline IID'),
+ label: __('Pipeline IID'),
+ value: 'iid',
+ },
+];
+
+export const RAW_TEXT_WARNING = s__(
+ 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
+);
+
+export const TRACKING_CATEGORIES = {
+ table: 'pipelines_table_component',
+ tabs: 'pipelines_filter_tabs',
+ search: 'pipelines_filtered_search',
+ failed: 'pipeline_failed_jobs_tab',
+ tests: 'pipeline_tests_tab',
+};
diff --git a/app/assets/javascripts/ci/pipeline_details/event_hub.js b/app/assets/javascripts/ci/event_hub.js
index e31806ad199..e31806ad199 100644
--- a/app/assets/javascripts/ci/pipeline_details/event_hub.js
+++ b/app/assets/javascripts/ci/event_hub.js
diff --git a/app/assets/javascripts/ci/pipeline_details/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js
index 93ca3738ff0..bf312e66144 100644
--- a/app/assets/javascripts/ci/pipeline_details/constants.js
+++ b/app/assets/javascripts/ci/pipeline_details/constants.js
@@ -1,22 +1,11 @@
-import { s__, __ } from '~/locale';
+import { __, s__ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
-export const FILTER_PIPELINES_SEARCH_DELAY = 200;
-export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source'];
-export const FILTER_TAG_IDENTIFIER = 'tag';
export const SCHEDULE_ORIGIN = 'schedule';
export const NEEDS_PROPERTY = 'needs';
export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds';
-export const ICONS = {
- TAG: 'tag',
- MR: 'git-merge',
- BRANCH: 'branch',
- RETRY: 'retry',
- SUCCESS: 'success',
-};
-
export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
@@ -25,13 +14,6 @@ export const TestStatus = {
UNKNOWN: 'unknown',
};
-export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
-export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.');
-export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.');
-export const RAW_TEXT_WARNING = s__(
- 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
-);
-
/* Error constants shared across graphs */
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
@@ -64,25 +46,8 @@ export const validPipelineTabNames = [
codeQualityTabName,
];
-// Constants for the ID and IID selection dropdown
-export const PipelineKeyOptions = [
- {
- text: __('Show Pipeline ID'),
- label: __('Pipeline ID'),
- value: 'id',
- },
- {
- text: __('Show Pipeline IID'),
- label: __('Pipeline IID'),
- value: 'iid',
- },
-];
-
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
-export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs');
-export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline');
-
export const DEFAULT_FIELDS = [
{
key: 'name',
@@ -107,14 +72,6 @@ export const DEFAULT_FIELDS = [
},
];
-export const TRACKING_CATEGORIES = {
- table: 'pipelines_table_component',
- tabs: 'pipelines_filter_tabs',
- search: 'pipelines_filtered_search',
- failed: 'pipeline_failed_jobs_tab',
- tests: 'pipeline_tests_tab',
-};
-
// Pipeline Mini Graph
export const PIPELINE_MINI_GRAPH_POLL_INTERVAL = 5000;
diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue
index 4a15f5b581a..3a6a655bfa6 100644
--- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue
+++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue
@@ -11,6 +11,7 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import { __, s__, sprintf, formatNumber } from '~/locale';
@@ -19,14 +20,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import {
- LOAD_FAILURE,
- POST_FAILURE,
- DELETE_FAILURE,
- DEFAULT,
- BUTTON_TOOLTIP_RETRY,
- BUTTON_TOOLTIP_CANCEL,
-} from '../constants';
+import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants';
import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue
index 98431bd1fcc..4752fbb3e96 100644
--- a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue
+++ b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue
@@ -6,8 +6,9 @@ import { createAlert } from '~/alert';
import Tracking from '~/tracking';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
import RetryFailedJobMutation from '../graphql/mutations/retry_failed_job.mutation.graphql';
-import { DEFAULT_FIELDS, TRACKING_CATEGORIES } from '../../constants';
+import { DEFAULT_FIELDS } from '../../constants';
export default {
fields: DEFAULT_FIELDS,
diff --git a/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js
index 1679adf4738..53f755fda37 100644
--- a/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js
@@ -1,5 +1,6 @@
import Visibility from 'visibilityjs';
import { createAlert } from '~/alert';
+import eventHub from '~/ci/event_hub';
import { helpPagePath } from '~/helpers/help_page_helper';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
@@ -7,7 +8,6 @@ import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
import { validateParams } from '~/ci/pipeline_details/utils';
import { CANCEL_REQUEST, TOAST_MESSAGE } from '../constants';
-import eventHub from '../event_hub';
export default {
data() {
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js
index 86b565d7821..d38397e7479 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js
+++ b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js
@@ -10,7 +10,7 @@ import {
import { doesHashExistInUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import Translate from '~/vue_shared/translate';
-import Pipelines from './pipelines_list/pipelines.vue';
+import Pipelines from '~/ci/pipelines_page/pipelines.vue';
import PipelinesStore from './stores/pipelines_store';
Vue.use(Translate);
diff --git a/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue b/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue
index 35dde6379dd..9783a9b5937 100644
--- a/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue
+++ b/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue
@@ -1,5 +1,6 @@
<script>
import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
import { __ } from '~/locale';
import Tracking from '~/tracking';
import {
@@ -8,7 +9,6 @@ import {
needsTabName,
pipelineTabName,
testReportTabName,
- TRACKING_CATEGORIES,
} from '../constants';
export default {
diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue
index 6da4ff2b0c2..d20d4aec59d 100644
--- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue
+++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import ActionComponent from '~/ci/common/private/job_action_component.vue';
import JobNameComponent from '~/ci/common/private/job_name_component.vue';
-import { ICONS } from '~/ci/pipeline_details/constants';
+import { ICONS } from '~/ci/constants';
import delayedJobMixin from '~/ci/mixins/delayed_job_mixin';
import { s__, sprintf } from '~/locale';
import { reportToSentry } from '~/ci/utils';
diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue
index 682393d8901..bbe0f1fbefc 100644
--- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue
+++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue
@@ -15,7 +15,7 @@
import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { createAlert } from '~/alert';
-import eventHub from '~/ci/pipeline_details/event_hub';
+import eventHub from '~/ci/event_hub';
import axios from '~/lib/utils/axios_utils';
import { __, s__, sprintf } from '~/locale';
import LegacyJobItem from './legacy_job_item.vue';
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue
index 439dc0eb253..439dc0eb253 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue
index 1a2021df9c8..1a2021df9c8 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue
index 6e7d6908cd9..6e7d6908cd9 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue
index a6297213402..a6297213402 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue
index 82f1d57912a..82f1d57912a 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue
index 375f72bb72f..138269bdb8a 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue
@@ -4,7 +4,7 @@ import { createAlert } from '~/alert';
import { __, s__, sprintf } from '~/locale';
import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils';
import { graphqlEtagPipelinePath } from '~/ci/pipeline_details/utils';
-import getPipelineFailedJobs from '~/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs.query.graphql';
+import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql';
import { sortJobsByStatus } from './utils';
import FailedJobDetails from './failed_job_details.vue';
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue
index c01037e9791..c01037e9791 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js
index 3f395fff7e0..3f395fff7e0 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/failure_widget/utils.js
+++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/nav_controls.vue b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue
index 235126fea0c..235126fea0c 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/nav_controls.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_labels.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue
index 082ede60244..082ede60244 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_labels.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue
index 78acead95f4..78acead95f4 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue
index 8f275bee91f..b05bdae65c4 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import Tracking from '~/tracking';
-import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '../../constants';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '~/ci/constants';
import eventHub from '../../event_hub';
import PipelineMultiActions from './pipeline_multi_actions.vue';
import PipelinesManualActions from './pipelines_manual_actions.vue';
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue
index 9f38be668f2..9f38be668f2 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue
index 2a73795db0a..2a73795db0a 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_url.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue
index 10ed3decd2c..edaeb481d7b 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_url.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue
@@ -4,7 +4,7 @@ import { __ } from '~/locale';
import Tracking from '~/tracking';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { ICONS, TRACKING_CATEGORIES } from '../../constants';
+import { ICONS, TRACKING_CATEGORIES } from '~/ci/constants';
import PipelineLabels from './pipeline_labels.vue';
export default {
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_artifacts.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue
index 3021b4a2ef8..3021b4a2ef8 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue
index 6aadb6b73c8..6aadb6b73c8 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue
index 4dacd474bde..4dacd474bde 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_status_badge.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue
index 050dd486cbd..2da9141df8e 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_status_badge.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue
@@ -1,5 +1,6 @@
<script>
-import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+import { CHILD_VIEW } from '~/ci/pipeline_details/constants';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import Tracking from '~/tracking';
import PipelinesTimeago from './time_ago.vue';
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/time_ago.vue b/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue
index 70343544638..70343544638 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/components/time_ago.vue
+++ b/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue
diff --git a/app/assets/javascripts/ci/pipelines_page/constants.js b/app/assets/javascripts/ci/pipelines_page/constants.js
new file mode 100644
index 00000000000..aa6ef8a25ee
--- /dev/null
+++ b/app/assets/javascripts/ci/pipelines_page/constants.js
@@ -0,0 +1,2 @@
+export const ANY_TRIGGER_AUTHOR = 'Any';
+export const FILTER_PIPELINES_SEARCH_DELAY = 200;
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql
index d1878c01e91..d1878c01e91 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_actions.query.graphql
+++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql
index 6b553866f63..6b553866f63 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs.query.graphql
+++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs_count.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql
index b70e95deab6..b70e95deab6 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs_count.query.graphql
+++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/pipelines.vue b/app/assets/javascripts/ci/pipelines_page/pipelines.vue
index bc169236e35..87ee5463bb0 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/ci/pipelines_page/pipelines.vue
@@ -7,24 +7,24 @@ import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
-import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-import { isLoggedIn } from '~/lib/utils/common_utils';
-import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import {
- ANY_TRIGGER_AUTHOR,
- RAW_TEXT_WARNING,
FILTER_TAG_IDENTIFIER,
PipelineKeyOptions,
+ RAW_TEXT_WARNING,
TRACKING_CATEGORIES,
-} from '../constants';
-import PipelinesMixin from '../mixins/pipelines_mixin';
-import PipelinesService from '../services/pipelines_service';
-import { validateParams } from '../utils';
-import NoCiEmptyState from './empty_state/no_ci_empty_state.vue';
+} from '~/ci/constants';
+import PipelinesTableComponent from '~/ci/common/pipelines_table.vue';
+import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin';
+import { validateParams } from '~/ci/pipeline_details/utils';
+import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
+import PipelinesService from './services/pipelines_service';
+import { ANY_TRIGGER_AUTHOR } from './constants';
+import NoCiEmptyState from './components/empty_state/no_ci_empty_state.vue';
import NavigationControls from './components/nav_controls.vue';
import PipelinesFilteredSearch from './components/pipelines_filtered_search.vue';
-import PipelinesTableComponent from './components/pipelines_table.vue';
export default {
PipelineKeyOptions,
diff --git a/app/assets/javascripts/ci/pipeline_details/services/pipelines_service.js b/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js
index 3ec563c95bb..c38fa07c7e3 100644
--- a/app/assets/javascripts/ci/pipeline_details/services/pipelines_service.js
+++ b/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js
@@ -1,6 +1,6 @@
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
-import { validateParams } from '../utils';
+import { validateParams } from '../../pipeline_details/utils';
export default class PipelinesService {
/**
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/constants.js b/app/assets/javascripts/ci/pipelines_page/tokens/constants.js
index d8f15cfde91..d8f15cfde91 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/constants.js
+++ b/app/assets/javascripts/ci/pipelines_page/tokens/constants.js
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue
index 5c2c1aa03d5..45b6fb380a9 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue
@@ -3,7 +3,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from
import { debounce } from 'lodash';
import Api from '~/api';
import { createAlert } from '~/alert';
-import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import { __ } from '~/locale';
+import { FILTER_PIPELINES_SEARCH_DELAY } from '../constants';
export default {
components: {
@@ -46,7 +47,7 @@ export default {
})
.catch((err) => {
createAlert({
- message: FETCH_BRANCH_ERROR_MESSAGE,
+ message: __('There was a problem fetching project branches.'),
});
this.loading = false;
throw err;
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue
index 03d9e6478ac..b4b5c5c1b37 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token.vue
+++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipeline_details/pipelines_list/tokens/constants';
+import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants';
export default {
PIPELINE_SOURCES,
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue
index 020a08b8cee..020a08b8cee 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token.vue
+++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue
index ceb6176df3d..a6034e78b6d 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token.vue
+++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue
@@ -3,7 +3,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from
import { debounce } from 'lodash';
import Api from '~/api';
import { createAlert } from '~/alert';
-import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import { __ } from '~/locale';
+import { FILTER_PIPELINES_SEARCH_DELAY } from '../constants';
export default {
components: {
@@ -39,7 +40,7 @@ export default {
})
.catch((err) => {
createAlert({
- message: FETCH_TAG_ERROR_MESSAGE,
+ message: __('There was a problem fetching project tags.'),
});
this.loading = false;
throw err;
diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue
index 8c516cc8cb3..20c5e1557a7 100644
--- a/app/assets/javascripts/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue
@@ -9,11 +9,8 @@ import {
import { debounce } from 'lodash';
import Api from '~/api';
import { createAlert } from '~/alert';
-import {
- ANY_TRIGGER_AUTHOR,
- FETCH_AUTHOR_ERROR_MESSAGE,
- FILTER_PIPELINES_SEARCH_DELAY,
-} from '../../constants';
+import { __ } from '~/locale';
+import { ANY_TRIGGER_AUTHOR, FILTER_PIPELINES_SEARCH_DELAY } from '../constants';
export default {
anyTriggerAuthor: ANY_TRIGGER_AUTHOR,
@@ -62,7 +59,7 @@ export default {
})
.catch((err) => {
createAlert({
- message: FETCH_AUTHOR_ERROR_MESSAGE,
+ message: __('There was a problem fetching project users.'),
});
this.loading = false;
throw err;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 589acc76926..5e84dcbe48e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -2,11 +2,11 @@
import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getParameterByName } from '~/lib/utils/url_utility';
-import PipelinesTableComponent from '~/ci/pipeline_details/pipelines_list/components/pipelines_table.vue';
-import { PipelineKeyOptions } from '~/ci/pipeline_details/constants';
-import eventHub from '~/ci/pipeline_details/event_hub';
+import PipelinesTableComponent from '~/ci/common/pipelines_table.vue';
+import { PipelineKeyOptions } from '~/ci/constants';
+import eventHub from '~/ci/event_hub';
import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin';
-import PipelinesService from '~/ci/pipeline_details/services/pipelines_service';
+import PipelinesService from '~/ci/pipelines_page/services/pipelines_service';
import PipelineStore from '~/ci/pipeline_details/stores/pipelines_store';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue
new file mode 100644
index 00000000000..df432a1928a
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_hover_peek_behavior.vue
@@ -0,0 +1,126 @@
+<script>
+import { getCssClassDimensions } from '~/lib/utils/css_utils';
+import Tracking from '~/tracking';
+import {
+ JS_TOGGLE_EXPAND_CLASS,
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+ SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE,
+} from '../constants';
+
+export default {
+ name: 'SidebarHoverPeek',
+ mixins: [Tracking.mixin()],
+ props: {
+ isMouseOverSidebar: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ created() {
+ // Nothing needs to observe these properties, so they are not reactive.
+ this.state = null;
+ this.openTimer = null;
+ this.closeTimer = null;
+ this.xSidebarEdge = null;
+ this.isMouseWithinSidebarArea = false;
+ },
+ async mounted() {
+ await this.$nextTick();
+ this.xSidebarEdge = getCssClassDimensions('super-sidebar').width;
+ document.addEventListener('mousemove', this.onMouseMove);
+ document.documentElement.addEventListener('mouseleave', this.onDocumentLeave);
+ document
+ .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)
+ .addEventListener('mouseenter', this.onMouseEnter);
+ document
+ .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)
+ .addEventListener('mouseleave', this.onMouseLeave);
+ this.changeState(STATE_CLOSED);
+ },
+ beforeDestroy() {
+ document.removeEventListener('mousemove', this.onMouseMove);
+ document.documentElement.removeEventListener('mouseleave', this.onDocumentLeave);
+ document
+ .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)
+ .removeEventListener('mouseenter', this.onMouseEnter);
+ document
+ .querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)
+ .removeEventListener('mouseleave', this.onMouseLeave);
+ this.clearTimers();
+ },
+ methods: {
+ onMouseMove({ clientX }) {
+ if (clientX < this.xSidebarEdge) {
+ this.isMouseWithinSidebarArea = true;
+ } else {
+ this.isMouseWithinSidebarArea = false;
+ if (!this.isMouseOverSidebar && this.state === STATE_OPEN) {
+ this.willClose();
+ }
+ }
+ },
+ onDocumentLeave() {
+ this.isMouseWithinSidebarArea = false;
+ if (this.state === STATE_OPEN) {
+ this.willClose();
+ } else if (this.state === STATE_WILL_OPEN) {
+ this.close();
+ }
+ },
+ onMouseEnter() {
+ clearTimeout(this.closeTimer);
+ this.willOpen();
+ },
+ onMouseLeave() {
+ clearTimeout(this.openTimer);
+ if (this.isMouseWithinSidebarArea || this.isMouseOverSidebar) return;
+ this.willClose();
+ },
+ willClose() {
+ this.changeState(STATE_WILL_CLOSE);
+ this.closeTimer = setTimeout(this.close, SUPER_SIDEBAR_PEEK_CLOSE_DELAY);
+ },
+ willOpen() {
+ this.changeState(STATE_WILL_OPEN);
+ this.openTimer = setTimeout(this.open, SUPER_SIDEBAR_PEEK_OPEN_DELAY);
+ },
+ open() {
+ this.changeState(STATE_OPEN);
+ this.clearTimers();
+ this.track('nav_hover_peek', {
+ label: 'nav_sidebar_toggle',
+ property: 'nav_sidebar',
+ });
+ },
+ close() {
+ if (this.isMouseWithinSidebarArea) return;
+ this.changeState(STATE_CLOSED);
+ this.clearTimers();
+ },
+ clearTimers() {
+ clearTimeout(this.closeTimer);
+ clearTimeout(this.openTimer);
+ },
+ /**
+ * Switches to the new state, and emits a change event.
+ *
+ * If the given state is the current state, do nothing.
+ *
+ * @param {string} state The state to transition to.
+ */
+ changeState(state) {
+ if (this.state === state) return;
+ this.state = state;
+ this.$emit('change', state);
+ },
+ },
+ render() {
+ return null;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
index ec728b4af9e..a20e37b945a 100644
--- a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
+++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue
@@ -1,12 +1,14 @@
<script>
import { getCssClassDimensions } from '~/lib/utils/css_utils';
import Tracking from '~/tracking';
-import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants';
-
-export const STATE_CLOSED = 'closed';
-export const STATE_WILL_OPEN = 'will-open';
-export const STATE_OPEN = 'open';
-export const STATE_WILL_CLOSE = 'will-close';
+import {
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+ SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE,
+} from '../constants';
export default {
name: 'SidebarPeek',
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index fa366deeac8..2c939487784 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -4,14 +4,20 @@ import { Mousetrap } from '~/lib/mousetrap';
import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings';
import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
-import { sidebarState } from '../constants';
+import {
+ sidebarState,
+ SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN,
+} from '../constants';
import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager';
import { trackContextAccess } from '../utils';
import UserBar from './user_bar.vue';
import SidebarPortalTarget from './sidebar_portal_target.vue';
import HelpCenter from './help_center.vue';
import SidebarMenu from './sidebar_menu.vue';
-import SidebarPeekBehavior, { STATE_CLOSED, STATE_WILL_OPEN } from './sidebar_peek_behavior.vue';
+import SidebarPeekBehavior from './sidebar_peek_behavior.vue';
+import SidebarHoverPeekBehavior from './sidebar_hover_peek_behavior.vue';
export default {
components: {
@@ -20,6 +26,7 @@ export default {
HelpCenter,
SidebarMenu,
SidebarPeekBehavior,
+ SidebarHoverPeekBehavior,
SidebarPortalTarget,
TrialStatusWidget: () =>
import('ee_component/contextual_sidebar/components/trial_status_widget.vue'),
@@ -43,16 +50,21 @@ export default {
sidebarState,
showPeekHint: false,
isMouseover: false,
+ breakpoint: null,
};
},
computed: {
+ showOverlay() {
+ return this.sidebarState.isPeek || this.sidebarState.isHoverPeek;
+ },
menuItems() {
return this.sidebarData.current_menu_items || [];
},
peekClasses() {
return {
'super-sidebar-peek-hint': this.showPeekHint,
- 'super-sidebar-peek': this.sidebarState.isPeek,
+ 'super-sidebar-peek': this.showOverlay,
+ 'super-sidebar-has-peeked': this.sidebarState.hasPeeked,
};
},
},
@@ -90,6 +102,7 @@ export default {
this.sidebarState.isCollapsed = true;
this.showPeekHint = false;
} else if (state === STATE_WILL_OPEN) {
+ this.sidebarState.hasPeeked = true;
this.sidebarState.isPeek = false;
this.sidebarState.isCollapsed = true;
this.showPeekHint = true;
@@ -99,6 +112,16 @@ export default {
this.showPeekHint = false;
}
},
+ onHoverPeekChange(state) {
+ if (state === STATE_OPEN) {
+ this.sidebarState.hasPeeked = true;
+ this.sidebarState.isHoverPeek = true;
+ this.sidebarState.isCollapsed = false;
+ } else if (state === STATE_CLOSED) {
+ this.sidebarState.isHoverPeek = false;
+ this.sidebarState.isCollapsed = true;
+ }
+ },
},
};
</script>
@@ -124,7 +147,7 @@ export default {
@mouseenter="isMouseover = true"
@mouseleave="isMouseover = false"
>
- <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" />
+ <user-bar :has-collapse-button="!showOverlay" :sidebar-data="sidebarData" />
<div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2">
<trial-status-widget
class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! nav-item-link gl-py-3"
@@ -165,13 +188,18 @@ export default {
</a>
<!--
- Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid
+ Only mount peek behavior components if the sidebar is peekable, to avoid
setting up event listeners unnecessarily.
-->
<sidebar-peek-behavior
- v-if="sidebarState.isPeekable"
+ v-if="sidebarState.isPeekable && !sidebarState.isHoverPeek"
:is-mouse-over-sidebar="isMouseover"
@change="onPeekChange"
/>
+ <sidebar-hover-peek-behavior
+ v-if="sidebarState.isPeekable && !sidebarState.isPeek"
+ :is-mouse-over-sidebar="isMouseover"
+ @change="onHoverPeekChange"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
index 49435310793..f3f7dd587db 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue
@@ -27,19 +27,18 @@ export default {
},
i18n: {
collapseSidebar: __('Hide sidebar'),
- expandSidebar: __('Show sidebar'),
+ expandSidebar: __('Keep sidebar visible'),
primaryNavigationSidebar: __('Primary navigation sidebar'),
},
data() {
return sidebarState;
},
computed: {
+ canOpen() {
+ return this.isCollapsed || this.isPeek || this.isHoverPeek;
+ },
tooltipTitle() {
- if (this.isPeek) return '';
-
- return this.isCollapsed
- ? this.$options.i18n.expandSidebar
- : this.$options.i18n.collapseSidebar;
+ return this.canOpen ? this.$options.i18n.expandSidebar : this.$options.i18n.collapseSidebar;
},
tooltip() {
return {
@@ -49,21 +48,21 @@ export default {
};
},
ariaExpanded() {
- return String(!this.isCollapsed);
+ return String(!this.canOpen);
},
},
methods: {
toggle() {
- this.track(this.isCollapsed ? 'nav_show' : 'nav_hide', {
+ this.track(this.canOpen ? 'nav_show' : 'nav_hide', {
label: 'nav_toggle',
property: 'nav_sidebar',
});
- toggleSuperSidebarCollapsed(!this.isCollapsed, true);
+ toggleSuperSidebarCollapsed(!this.canOpen, true);
this.focusOtherToggle();
},
focusOtherToggle() {
this.$nextTick(() => {
- const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS;
+ const classSelector = this.canOpen ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS;
const otherToggle = document.querySelector(`.${classSelector}`);
otherToggle?.focus();
});
@@ -80,7 +79,6 @@ export default {
:aria-label="$options.i18n.primaryNavigationSidebar"
icon="sidebar"
category="tertiary"
- :disabled="isPeek"
@click="toggle"
/>
</template>
diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js
index 0abc459bc52..77bd8b4a734 100644
--- a/app/assets/javascripts/super_sidebar/constants.js
+++ b/app/assets/javascripts/super_sidebar/constants.js
@@ -14,8 +14,11 @@ export const portalState = Vue.observable({
export const sidebarState = Vue.observable({
isCollapsed: false,
+ hasPeeked: false,
isPeek: false,
isPeekable: false,
+ isHoverPeek: false,
+ wasHoverPeek: false,
});
export const helpCenterState = Vue.observable({
@@ -27,6 +30,10 @@ export const MAX_FREQUENT_GROUPS_COUNT = 3;
export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200;
export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500;
+export const SUPER_SIDEBAR_PEEK_STATE_CLOSED = 'closed';
+export const SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN = 'will-open';
+export const SUPER_SIDEBAR_PEEK_STATE_OPEN = 'open';
+export const SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE = 'will-close';
export const TRACKING_UNKNOWN_ID = 'item_without_id';
export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown';
diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
index feb7e274b07..9ee78a657b6 100644
--- a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
+++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js
@@ -26,6 +26,9 @@ export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => {
sidebarState.isPeek = false;
sidebarState.isPeekable = collapsed;
+ sidebarState.hasPeeked = false;
+ sidebarState.isHoverPeek = false;
+ sidebarState.wasHoverPeek = false;
sidebarState.isCollapsed = collapsed;
if (saveCookie && isDesktopBreakpoint()) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 903f946e1c6..bfcd4610379 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -12,7 +12,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__, n__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils';
-import PipelineArtifacts from '~/ci/pipeline_details/pipelines_list/components/pipelines_artifacts.vue';
+import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue';
import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss
index d2623204982..75031cac416 100644
--- a/app/assets/stylesheets/framework/super_sidebar.scss
+++ b/app/assets/stylesheets/framework/super_sidebar.scss
@@ -185,6 +185,15 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
display: none;
}
+.super-sidebar-has-peeked {
+ margin-top: calc(#{$header-height} - #{$gl-spacing-scale-2});
+ margin-bottom: #{$gl-spacing-scale-2};
+}
+
+.super-sidebar-peek {
+ margin-left: #{$gl-spacing-scale-2};
+}
+
.super-sidebar-peek,
.super-sidebar-peek-hint {
@include gl-shadow;
@@ -197,6 +206,14 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4;
}
}
+.super-sidebar-peek {
+ border-radius: $border-radius-default;
+
+ .user-bar {
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ }
+}
+
.page-with-super-sidebar {
padding-left: 0;
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index e73e2a38149..fce7de4c0de 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -34,7 +34,7 @@ class Projects::GraphsController < Projects::ApplicationController
{
author_name: commit.author_name,
author_email: commit.author_email,
- date: commit.committed_date.strftime("%Y-%m-%d")
+ date: commit.committed_date.to_date.iso8601
}
end
diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb
index 6432e4fc682..c2c142bca4d 100644
--- a/app/helpers/registrations_helper.rb
+++ b/app/helpers/registrations_helper.rb
@@ -7,7 +7,7 @@ module RegistrationsHelper
min_length_message: s_('SignUp|Username is too short (minimum is %{min_length} characters).') % { min_length: User::MIN_USERNAME_LENGTH },
max_length: User::MAX_USERNAME_LENGTH,
max_length_message: s_('SignUp|Username is too long (maximum is %{max_length} characters).') % { max_length: User::MAX_USERNAME_LENGTH },
- testid: 'new_user_username_field'
+ testid: 'new-user-username-field'
}
end
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 6d37257232b..bf1b604465b 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -17,7 +17,7 @@
class: 'form-control gl-form-input top js-block-emoji js-validate-length',
data: { max_length: max_first_name_length,
max_length_message: s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length },
- qa_selector: 'new_user_first_name_field' },
+ testid: 'new-user-first-name-field' },
required: true,
title: _('This field is required.')
.col.form-group
@@ -26,7 +26,7 @@
class: 'form-control gl-form-input top js-block-emoji js-validate-length',
data: { max_length: max_last_name_length,
max_length_message: s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length },
- qa_selector: 'new_user_last_name_field' },
+ testid: 'new-user-last-name-field' },
required: true,
title: _('This field is required.')
.username.form-group
@@ -44,7 +44,7 @@
= f.label :email, _('Email')
= f.email_field :email,
class: 'form-control gl-form-input middle js-validate-email',
- data: { qa_selector: 'new_user_email_field' },
+ data: { testid: 'new-user-email-field' },
required: true,
title: _('Please provide a valid email address.')
%p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.')
@@ -56,7 +56,7 @@
%input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password",
title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length },
minimum_password_length: @minimum_password_length,
- qa_selector: 'new_user_password_field',
+ testid: 'new-user-password-field',
autocomplete: 'new-password',
name: "#{form_resource_name}[password]" } }
%p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
@@ -69,7 +69,7 @@
- elsif show_recaptcha_sign_up?
= recaptcha_tags nonce: content_security_policy_nonce
- = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { qa_selector: 'new_user_register_button' }}) do
+ = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'new-user-register-button' }}) do
= button_text
= render 'devise/shared/terms_of_service_notice', button_text: button_text
diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder
index c429bbbb610..43a545c4b4e 100644
--- a/app/views/events/_event.atom.builder
+++ b/app/views/events/_event.atom.builder
@@ -6,7 +6,7 @@ event = event.present
event_url = event_feed_url(event)
xml.entry do
- xml.id "tag:#{request.host},#{event.created_at.strftime("%Y-%m-%d")}:#{event.id}"
+ xml.id "tag:#{request.host},#{event.created_at.to_date.iso8601}:#{event.id}"
xml.link href: event_url if event_url
xml.title truncate(event_feed_title(event), length: 80)
xml.updated event.updated_at.xmlschema
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index bf6db989515..5d4e9a90018 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -160,6 +160,10 @@ if github_settings
end
end
+# Fill out default Settings for omniauth-saml
+
+OmniAuth::Strategies::SAML.default_options['message_max_bytesize'] = Settings.omniauth['saml_message_max_byte_size']
+
# SAML should be enabled for the tests automatically, but only for EE.
saml_provider_enabled = Settings.omniauth.providers.any? do |provider|
provider['name'] == 'group_saml'
diff --git a/doc/administration/settings/security_and_compliance.md b/doc/administration/settings/security_and_compliance.md
index c3f947d2971..648e85b0d40 100644
--- a/doc/administration/settings/security_and_compliance.md
+++ b/doc/administration/settings/security_and_compliance.md
@@ -11,10 +11,6 @@ The settings for package metadata synchronization are located in the [Admin Area
## Choose package registry metadata to sync
-WARNING:
-The full package metadata sync can add up to 30 GB to the PostgreSQL database. Ensure you have provisioned enough disk space for the database before enabling this feature.
-We are actively working on reducing this data size in [epic 10415](https://gitlab.com/groups/gitlab-org/-/epics/10415).
-
To choose the packages you want to synchronize with the GitLab License Database for [License Compliance](../../user/compliance/license_scanning_of_cyclonedx_files/index.md):
1. On the left sidebar, select **Search or go to**.
diff --git a/doc/development/gems.md b/doc/development/gems.md
index c061b33b5e4..132bf931da8 100644
--- a/doc/development/gems.md
+++ b/doc/development/gems.md
@@ -238,11 +238,10 @@ The project for a new Gem should always be created in [`gitlab-org/ruby/gems` na
the gem name with `gitlab-`. For example, `gitlab-sidekiq-fetcher`.
1. Locally create the gem or fork as necessary.
1. [Publish an empty `0.0.1` version of the gem to rubygems.org](https://guides.rubygems.org/publishing/#publishing-to-rubygemsorg) to ensure the gem name is reserved.
-1. Add the [`gitlab_rubygems`](https://rubygems.org/profiles/gitlab_rubygems) and [`gitlab-qa`](https://rubygems.org/profiles/gitlab-qa) users as owners of the new gem by running:
+1. Add the [`gitlab_rubygems`](https://rubygems.org/profiles/gitlab_rubygems) user as owner of the new gem by running:
```shell
gem owner <gem-name> --add gitlab_rubygems
- gem owner <gem-name> --add gitlab-qa
```
1. Optional. Add some or all of the following users as co-owners:
@@ -251,8 +250,8 @@ The project for a new Gem should always be created in [`gitlab-org/ruby/gems` na
- [Stan Hu](https://rubygems.org/profiles/stanhu)
1. Optional. Add any other relevant developers as co-owners.
1. Visit `https://rubygems.org/gems/<gem-name>` and verify that the gem was published
- successfully and `gitlab_rubygems` & `gitlab-qa` are also owners.
-1. Create a project in the [`gitlab-org/ruby/gems` group](https://gitlab.com/gitlab-org/ruby/gems/). To create this project:
+ successfully and `gitlab_rubygems` is also an owner.
+1. Create a project in the [`gitlab-org/ruby/gems` group](https://gitlab.com/gitlab-org/ruby/gems/) (or in a subgroup of it):
1. Follow the [instructions for new projects](https://about.gitlab.com/handbook/engineering/gitlab-repositories/#creating-a-new-project).
1. Follow the instructions for setting up a [CI/CD configuration](https://about.gitlab.com/handbook/engineering/gitlab-repositories/#cicd-configuration).
1. Use the [shared CI/CD config](https://gitlab.com/gitlab-org/quality/pipeline-common/-/blob/master/ci/gem-release.yml)
@@ -264,7 +263,7 @@ The project for a new Gem should always be created in [`gitlab-org/ruby/gems` na
file: '/ci/gem-release.yml'
```
- This job will handle building and publishing the gem (it uses a `gilab-qa` Rubygems.org
+ This job will handle building and publishing the gem (it uses a `gitlab_rubygems` Rubygems.org
API token inherited from the `gitlab-org/ruby/gems` group, in order to publish the gem
package), as well as creating the tag, release and populating its release notes by
using the
diff --git a/doc/user/compliance/license_scanning_of_cyclonedx_files/index.md b/doc/user/compliance/license_scanning_of_cyclonedx_files/index.md
index dd73cc7dec3..214949af19c 100644
--- a/doc/user/compliance/license_scanning_of_cyclonedx_files/index.md
+++ b/doc/user/compliance/license_scanning_of_cyclonedx_files/index.md
@@ -164,3 +164,29 @@ gemnasium-dependency_scanning:
- apk update && apk add jq
- jq '.components |= unique' gl-sbom-gem-bundler.cdx.json > tmp.json && mv tmp.json gl-sbom-gem-bundler.cdx.json
```
+
+### Remove unused license data
+
+License scanning changes (released in GitLab 15.9) required a significant amount of additional disk space to be available on the instances. This issue was resolved in GitLab 16.3 by the [Reduce package metadata table on-disk footprint](https://gitlab.com/groups/gitlab-org/-/epics/10415) epic. But if your instance was running license scanning between GitLab 15.9 and 16.3, you may want to remove the unneeded data.
+
+To remove the unneeded data:
+
+1. Check if the [package_metadata_synchronization](https://about.gitlab.com/releases/2023/02/22/gitlab-15-9-released/#new-license-compliance-scanner) feature flag is currently, or was previously enabled, and if so, disable it. Use [Rails console](../../../administration/operations/rails_console.md) to execute the following commands.
+
+ ```ruby
+ Feature.enabled?(:package_metadata_synchronization) && Feature.disable(:package_metadata_synchronization)
+ ```
+
+1. Check if there is deprecated data in the database:
+
+ ```ruby
+ PackageMetadata::PackageVersionLicense.count
+ PackageMetadata::PackageVersion.count
+ ```
+
+1. If there is deprecated data in the database, remove it by running the following commands in order:
+
+ ```ruby
+ PackageMetadata::PackageVersionLicense.delete_all
+ PackageMetadata::PackageVersion.delete_all
+ ```
diff --git a/gems/gem.gitlab-ci.yml b/gems/gem.gitlab-ci.yml
index 4e91f0cbe44..a379a887bdd 100644
--- a/gems/gem.gitlab-ci.yml
+++ b/gems/gem.gitlab-ci.yml
@@ -55,7 +55,7 @@ rubocop:
rspec:
extends: .ruby_matrix
script:
- - bundle exec rspec
+ - RAILS_ENV=test bundle exec rspec
coverage: '/LOC \((\d+\.\d+%)\) covered.$/'
artifacts:
expire_in: 31d
diff --git a/gems/gitlab-http/.gitignore b/gems/gitlab-http/.gitignore
new file mode 100644
index 00000000000..b04a8c840df
--- /dev/null
+++ b/gems/gitlab-http/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/gems/gitlab-http/.gitlab-ci.yml b/gems/gitlab-http/.gitlab-ci.yml
new file mode 100644
index 00000000000..cf85b7fcc2e
--- /dev/null
+++ b/gems/gitlab-http/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+include:
+ - local: gems/gem.gitlab-ci.yml
+ inputs:
+ gem_name: "gitlab-http"
diff --git a/gems/gitlab-http/.rspec b/gems/gitlab-http/.rspec
new file mode 100644
index 00000000000..34c5164d9b5
--- /dev/null
+++ b/gems/gitlab-http/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/gems/gitlab-http/.rubocop.yml b/gems/gitlab-http/.rubocop.yml
new file mode 100644
index 00000000000..fe6309f6ba5
--- /dev/null
+++ b/gems/gitlab-http/.rubocop.yml
@@ -0,0 +1,56 @@
+inherit_from:
+ - ../config/rubocop.yml
+
+Naming/ClassAndModuleCamelCase:
+ AllowedNames:
+ - HTTP_V2
+
+Layout/LineLength:
+ Enabled: false
+
+Performance/RegexpMatch:
+ Enabled: false
+
+Style/GuardClause:
+ Enabled: false
+
+Naming/RescuedExceptionsVariableName:
+ Enabled: false
+
+Style/OpenStructUse:
+ Enabled: false
+
+Lint/AssignmentInCondition:
+ Enabled: false
+
+Naming/InclusiveLanguage:
+ Enabled: false
+
+Style/SpecialGlobalVars:
+ Enabled: false
+
+Style/IfUnlessModifier:
+ Enabled: false
+
+Lint/DuplicateBranch:
+ Enabled: false
+
+RSpec/MultipleMemoizedHelpers:
+ Exclude:
+ - spec/**/*.rb
+
+RSpec/InstanceVariable:
+ Exclude:
+ - spec/**/*.rb
+
+RSpec/ContextWording:
+ Exclude:
+ - spec/**/*.rb
+
+RSpec/ExpectInHook:
+ Exclude:
+ - spec/**/*.rb
+
+RSpec/FilePath:
+ Exclude:
+ - spec/**/*.rb
diff --git a/gems/gitlab-http/Gemfile b/gems/gitlab-http/Gemfile
new file mode 100644
index 00000000000..a6a5c2a4bc1
--- /dev/null
+++ b/gems/gitlab-http/Gemfile
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Specify your gem's dependencies in gitlab-http.gemspec
+gemspec
+
+group :development, :test do
+ gem 'gitlab-rspec', path: '../gitlab-rspec'
+end
+
+gem 'gitlab-utils', path: '../gitlab-utils'
diff --git a/gems/gitlab-http/Gemfile.lock b/gems/gitlab-http/Gemfile.lock
new file mode 100644
index 00000000000..4afa39ef750
--- /dev/null
+++ b/gems/gitlab-http/Gemfile.lock
@@ -0,0 +1,185 @@
+PATH
+ remote: ../gitlab-rspec
+ specs:
+ gitlab-rspec (0.1.0)
+ activesupport (>= 6.1, < 7.1)
+ rspec (~> 3.0)
+
+PATH
+ remote: ../gitlab-utils
+ specs:
+ gitlab-utils (0.1.0)
+ actionview (>= 6.1.7.2)
+ activesupport (>= 6.1.7.2)
+ addressable (~> 2.8)
+ nokogiri (~> 1.15.2)
+ rake (~> 13.0)
+
+PATH
+ remote: .
+ specs:
+ gitlab-http (0.1.0)
+ activesupport (~> 7.0.6)
+ httparty (~> 0.21.0)
+ ipaddress (~> 0.8.3)
+ nokogiri (~> 1.15.4)
+ railties (~> 7.0.6)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actionpack (7.0.7)
+ actionview (= 7.0.7)
+ activesupport (= 7.0.7)
+ rack (~> 2.0, >= 2.2.4)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
+ actionview (7.0.7)
+ activesupport (= 7.0.7)
+ builder (~> 3.1)
+ erubi (~> 1.4)
+ rails-dom-testing (~> 2.0)
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
+ activesupport (7.0.7)
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ tzinfo (~> 2.0)
+ addressable (2.8.4)
+ public_suffix (>= 2.0.2, < 6.0)
+ ast (2.4.2)
+ builder (3.2.4)
+ concurrent-ruby (1.2.2)
+ crack (0.4.5)
+ rexml
+ crass (1.0.6)
+ diff-lcs (1.5.0)
+ erubi (1.12.0)
+ gitlab-styles (10.1.0)
+ rubocop (~> 1.50.2)
+ rubocop-graphql (~> 0.18)
+ rubocop-performance (~> 1.15)
+ rubocop-rails (~> 2.17)
+ rubocop-rspec (~> 2.22)
+ hashdiff (1.0.1)
+ httparty (0.21.0)
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ ipaddress (0.8.3)
+ json (2.6.3)
+ loofah (2.21.3)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ method_source (1.0.0)
+ mini_mime (1.1.2)
+ mini_portile2 (2.8.4)
+ minitest (5.18.1)
+ multi_xml (0.6.0)
+ nokogiri (1.15.4)
+ mini_portile2 (~> 2.8.2)
+ racc (~> 1.4)
+ parallel (1.23.0)
+ parser (3.2.2.3)
+ ast (~> 2.4.1)
+ racc
+ public_suffix (5.0.1)
+ racc (1.7.1)
+ rack (2.2.7)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rails-dom-testing (2.0.3)
+ activesupport (>= 4.2.0)
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
+ railties (7.0.7)
+ actionpack (= 7.0.7)
+ activesupport (= 7.0.7)
+ method_source
+ rake (>= 12.2)
+ thor (~> 1.0)
+ zeitwerk (~> 2.5)
+ rainbow (3.1.1)
+ rake (13.0.6)
+ regexp_parser (2.8.1)
+ rexml (3.2.5)
+ rspec (3.12.0)
+ rspec-core (~> 3.12.0)
+ rspec-expectations (~> 3.12.0)
+ rspec-mocks (~> 3.12.0)
+ rspec-core (3.12.2)
+ rspec-support (~> 3.12.0)
+ rspec-expectations (3.12.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-mocks (3.12.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.12.0)
+ rspec-rails (6.0.3)
+ actionpack (>= 6.1)
+ activesupport (>= 6.1)
+ railties (>= 6.1)
+ rspec-core (~> 3.12)
+ rspec-expectations (~> 3.12)
+ rspec-mocks (~> 3.12)
+ rspec-support (~> 3.12)
+ rspec-support (3.12.1)
+ rubocop (1.50.2)
+ json (~> 2.3)
+ parallel (~> 1.10)
+ parser (>= 3.2.0.0)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.29.0)
+ parser (>= 3.2.1.0)
+ rubocop-capybara (2.18.0)
+ rubocop (~> 1.41)
+ rubocop-factory_bot (2.23.1)
+ rubocop (~> 1.33)
+ rubocop-graphql (0.19.0)
+ rubocop (>= 0.87, < 2)
+ rubocop-performance (1.18.0)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.20.2)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ rubocop-rspec (2.23.0)
+ rubocop (~> 1.33)
+ rubocop-capybara (~> 2.17)
+ rubocop-factory_bot (~> 2.22)
+ ruby-progressbar (1.13.0)
+ thor (1.2.2)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.4.2)
+ webmock (3.18.1)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
+ zeitwerk (2.6.8)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ gitlab-http!
+ gitlab-rspec!
+ gitlab-styles (~> 10.1.0)
+ gitlab-utils!
+ rspec-rails (~> 6.0.3)
+ rubocop (~> 1.50.2)
+ rubocop-rspec (~> 2.22)
+ webmock (~> 3.18.1)
+
+BUNDLED WITH
+ 2.4.14
diff --git a/gems/gitlab-http/README.md b/gems/gitlab-http/README.md
new file mode 100644
index 00000000000..13ff330bb19
--- /dev/null
+++ b/gems/gitlab-http/README.md
@@ -0,0 +1,42 @@
+# Gitlab::HTTP_V2
+
+This gem is used as a proxy for all outbounding http connection
+coming from callbacks, services and hooks. The direct use of the HTTParty
+is discouraged because it can lead to several security problems, like SSRF
+calling internal IP or services.
+
+## Usage
+
+### Configuration
+
+```ruby
+Gitlab::HTTP_V2.configure do |config|
+ config.allowed_internal_uris = []
+
+ config.log_exception_proc = ->(exception, extra_info) do
+ # operation
+ end
+ config.silent_mode_log_info_proc = ->(message, http_method) do
+ # operation
+ end
+end
+```
+
+### Actions
+
+Basic examples;
+
+```ruby
+Gitlab::HTTP_V2.post(uri, body: body)
+
+Gitlab::HTTP_V2.try_get(uri, params)
+
+response = Gitlab::HTTP_V2.head(project_url, verify: true)
+
+Gitlab::HTTP_V2.post(path, base_uri: base_uri, **params)
+```
+
+## Development
+
+After checking out the repo, run `bundle` to install dependencies.
+Then, run `RACK_ENV=test bundle exec rspec spec` to run the tests.
diff --git a/gems/gitlab-http/gitlab-http.gemspec b/gems/gitlab-http/gitlab-http.gemspec
new file mode 100644
index 00000000000..3301864353f
--- /dev/null
+++ b/gems/gitlab-http/gitlab-http.gemspec
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require_relative "lib/gitlab/http_v2/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "gitlab-http"
+ spec.version = Gitlab::HTTP_V2::Version::VERSION
+ spec.authors = ["GitLab Engineers"]
+ spec.email = ["engineering@gmail.com"]
+
+ spec.summary = "GitLab HTTP client"
+ spec.description = "GitLab HTTP client"
+ spec.homepage = "https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-http"
+ spec.license = 'MIT'
+ spec.required_ruby_version = ">= 3.0"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ spec.files = Dir['lib/**/*.rb']
+ spec.test_files = Dir['spec/**/*']
+ spec.require_paths = ["lib"]
+
+ spec.add_runtime_dependency 'activesupport', '~> 7.0.6'
+ spec.add_runtime_dependency 'httparty', '~> 0.21.0'
+ spec.add_runtime_dependency 'ipaddress', '~> 0.8.3'
+ spec.add_runtime_dependency 'nokogiri', '~> 1.15.4'
+ spec.add_runtime_dependency "railties", "~> 7.0.6"
+
+ spec.add_development_dependency 'gitlab-styles', '~> 10.1.0'
+ spec.add_development_dependency 'rspec-rails', '~> 6.0.3'
+ spec.add_development_dependency "rubocop", "~> 1.50.2"
+ spec.add_development_dependency "rubocop-rspec", "~> 2.22"
+ spec.add_development_dependency 'webmock', '~> 3.18.1'
+end
diff --git a/gems/gitlab-http/lib/gitlab-http.rb b/gems/gitlab-http/lib/gitlab-http.rb
new file mode 100644
index 00000000000..1fc0e16ec9f
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab-http.rb
@@ -0,0 +1,11 @@
+# rubocop:disable Naming/FileName
+
+# frozen_string_literal: true
+
+# When we say gem 'gitlab-http' in Gemfile, bundler will also run require gitlab-http for us and it'd
+# resolve the conflict when we call `Gitlab::HTTP_V2.configure` first time.
+# See more: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125024#note_1502698924
+
+require_relative 'gitlab/http_v2'
+
+# rubocop:enable Naming/FileName
diff --git a/gems/gitlab-http/lib/gitlab/http_v2.rb b/gems/gitlab-http/lib/gitlab/http_v2.rb
new file mode 100644
index 00000000000..8f3ede95530
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require_relative "http_v2/configuration"
+require_relative "http_v2/patches"
+require_relative "http_v2/client"
+
+module Gitlab
+ module HTTP_V2
+ SUPPORTED_HTTP_METHODS = [:get, :try_get, :post, :patch, :put, :delete, :head, :options].freeze
+
+ class << self
+ delegate(*SUPPORTED_HTTP_METHODS, to: ::Gitlab::HTTP_V2::Client)
+
+ def configuration
+ @configuration ||= Configuration.new
+ end
+
+ def configure
+ yield(configuration)
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/buffered_io.rb b/gems/gitlab-http/lib/gitlab/http_v2/buffered_io.rb
new file mode 100644
index 00000000000..2787bde76d8
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/buffered_io.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'webmock' if Rails.env.test?
+
+# The Ruby 3.2 does change Net protocol. Please see;
+# https://github.com/ruby/ruby/blob/ruby_3_2/lib/net/protocol.rb#L194-L206
+# vs https://github.com/ruby/ruby/blob/ruby_3_1/lib/net/protocol.rb#L190-L200
+NET_PROTOCOL_VERSION_0_2_0 = Gem::Version.new(Net::Protocol::VERSION) >= Gem::Version.new('0.2.0')
+
+module Gitlab
+ module HTTP_V2
+ # Net::BufferedIO is overwritten by webmock but in order to test this class, it needs to inherit from the original BufferedIO.
+ # https://github.com/bblimke/webmock/blob/867f4b290fd133658aa9530cba4ba8b8c52c0d35/lib/webmock/http_lib_adapters/net_http.rb#L266
+ parent_class = if const_defined?('WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetBufferedIO') && Rails.env.test?
+ WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetBufferedIO
+ else
+ Net::BufferedIO
+ end
+
+ class BufferedIo < parent_class
+ HEADER_READ_TIMEOUT = 20
+
+ # rubocop: disable Style/RedundantReturn
+ # rubocop: disable Cop/LineBreakAfterGuardClauses
+ # rubocop: disable Layout/EmptyLineAfterGuardClause
+
+ # Original method:
+ # https://github.com/ruby/ruby/blob/cdb7d699d0641e8f081d590d06d07887ac09961f/lib/net/protocol.rb#L190-L200
+ def readuntil(terminator, ignore_eof = false, start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC))
+ if NET_PROTOCOL_VERSION_0_2_0
+ offset = @rbuf_offset
+ begin
+ until idx = @rbuf.index(terminator, offset)
+ if (elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) > HEADER_READ_TIMEOUT
+ raise Gitlab::HTTP_V2::HeaderReadTimeout, "Request timed out after reading headers for #{elapsed} seconds"
+ end
+
+ offset = @rbuf.bytesize
+ rbuf_fill
+ end
+
+ return rbuf_consume(idx + terminator.bytesize - @rbuf_offset)
+ rescue EOFError
+ raise unless ignore_eof
+ return rbuf_consume(@rbuf.size)
+ end
+ else
+ begin
+ until idx = @rbuf.index(terminator)
+ if (elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) > HEADER_READ_TIMEOUT
+ raise Gitlab::HTTP_V2::HeaderReadTimeout, "Request timed out after reading headers for #{elapsed} seconds"
+ end
+
+ rbuf_fill
+ end
+
+ return rbuf_consume(idx + terminator.size)
+ rescue EOFError
+ raise unless ignore_eof
+ return rbuf_consume(@rbuf.size)
+ end
+ end
+ end
+ # rubocop: enable Style/RedundantReturn
+ # rubocop: enable Cop/LineBreakAfterGuardClauses
+ # rubocop: enable Layout/EmptyLineAfterGuardClause
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/client.rb b/gems/gitlab-http/lib/gitlab/http_v2/client.rb
new file mode 100644
index 00000000000..8daf19d7351
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/client.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'httparty'
+require 'net/http'
+require 'active_support/all'
+require_relative 'new_connection_adapter'
+require_relative "exceptions"
+
+module Gitlab
+ module HTTP_V2
+ class Client
+ DEFAULT_TIMEOUT_OPTIONS = {
+ open_timeout: 10,
+ read_timeout: 20,
+ write_timeout: 30
+ }.freeze
+ DEFAULT_READ_TOTAL_TIMEOUT = 30.seconds
+
+ SILENT_MODE_ALLOWED_METHODS = [
+ Net::HTTP::Get,
+ Net::HTTP::Head,
+ Net::HTTP::Options,
+ Net::HTTP::Trace
+ ].freeze
+
+ include HTTParty # rubocop:disable Gitlab/HTTParty
+
+ class << self
+ alias_method :httparty_perform_request, :perform_request
+ end
+
+ connection_adapter NewConnectionAdapter
+
+ def self.perform_request(http_method, path, options, &block)
+ raise_if_blocked_by_silent_mode(http_method) if options.delete(:silent_mode_enabled)
+
+ log_info = options.delete(:extra_log_info)
+ options_with_timeouts =
+ if !options.has_key?(:timeout)
+ options.with_defaults(DEFAULT_TIMEOUT_OPTIONS)
+ else
+ options
+ end
+
+ if options[:stream_body]
+ httparty_perform_request(http_method, path, options_with_timeouts, &block)
+ else
+ begin
+ start_time = nil
+ read_total_timeout = options.fetch(:timeout, DEFAULT_READ_TOTAL_TIMEOUT)
+
+ httparty_perform_request(http_method, path, options_with_timeouts) do |fragment|
+ start_time ||= system_monotonic_time
+ elapsed = system_monotonic_time - start_time
+
+ raise ReadTotalTimeout, "Request timed out after #{elapsed} seconds" if elapsed > read_total_timeout
+
+ yield fragment if block
+ end
+ rescue HTTParty::RedirectionTooDeep
+ raise RedirectionTooDeep
+ rescue *HTTP_ERRORS => e
+ extra_info = log_info || {}
+ extra_info = log_info.call(e, path, options) if log_info.respond_to?(:call)
+ configuration.log_exception(e, extra_info)
+
+ raise e
+ end
+ end
+ end
+
+ def self.try_get(path, options = {}, &block)
+ self.get(path, options, &block) # rubocop:disable Style/RedundantSelf
+ rescue *HTTP_ERRORS
+ nil
+ end
+
+ def self.raise_if_blocked_by_silent_mode(http_method)
+ return if SILENT_MODE_ALLOWED_METHODS.include?(http_method)
+
+ configuration.silent_mode_log_info('Outbound HTTP request blocked', http_method.to_s)
+
+ raise SilentModeBlockedError, 'only get, head, options, and trace methods are allowed in silent mode'
+ end
+
+ def self.system_monotonic_time
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second)
+ end
+
+ def self.configuration
+ Gitlab::HTTP_V2.configuration
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/configuration.rb b/gems/gitlab-http/lib/gitlab/http_v2/configuration.rb
new file mode 100644
index 00000000000..98b07d0cf27
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/configuration.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module HTTP_V2
+ class Configuration
+ attr_accessor :allowed_internal_uris, :log_exception_proc, :silent_mode_log_info_proc
+
+ def log_exception(...)
+ log_exception_proc&.call(...)
+ end
+
+ def silent_mode_log_info(...)
+ silent_mode_log_info_proc&.call(...)
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/domain_allowlist_entry.rb b/gems/gitlab-http/lib/gitlab/http_v2/domain_allowlist_entry.rb
new file mode 100644
index 00000000000..5a08c891184
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/domain_allowlist_entry.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module HTTP_V2
+ class DomainAllowlistEntry
+ attr_reader :domain, :port
+
+ def initialize(domain, port: nil)
+ @domain = domain
+ @port = port
+ end
+
+ def match?(requested_domain, requested_port = nil)
+ return false unless domain == requested_domain
+ return true if port.nil?
+
+ port == requested_port
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/exceptions.rb b/gems/gitlab-http/lib/gitlab/http_v2/exceptions.rb
new file mode 100644
index 00000000000..5a34d0b9939
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/exceptions.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'net/http'
+
+module Gitlab
+ module HTTP_V2
+ BlockedUrlError = Class.new(StandardError)
+ RedirectionTooDeep = Class.new(StandardError)
+ ReadTotalTimeout = Class.new(Net::ReadTimeout)
+ HeaderReadTimeout = Class.new(Net::ReadTimeout)
+ SilentModeBlockedError = Class.new(StandardError)
+
+ HTTP_TIMEOUT_ERRORS = [
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Gitlab::HTTP_V2::ReadTotalTimeout
+ ].freeze
+
+ HTTP_ERRORS = HTTP_TIMEOUT_ERRORS + [
+ EOFError, SocketError, OpenSSL::SSL::SSLError, OpenSSL::OpenSSLError,
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
+ Gitlab::HTTP_V2::BlockedUrlError, Gitlab::HTTP_V2::RedirectionTooDeep,
+ Net::HTTPBadResponse
+ ].freeze
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/ip_allowlist_entry.rb b/gems/gitlab-http/lib/gitlab/http_v2/ip_allowlist_entry.rb
new file mode 100644
index 00000000000..ed5a2dba284
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/ip_allowlist_entry.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module HTTP_V2
+ class IpAllowlistEntry
+ attr_reader :ip, :port
+
+ # Argument ip should be an IPAddr object
+ def initialize(ip, port: nil)
+ @ip = ip
+ @port = port
+ end
+
+ def match?(requested_ip, requested_port = nil)
+ requested_ip = IPAddr.new(requested_ip) if requested_ip.is_a?(String)
+
+ return false unless ip_include?(requested_ip)
+ return true if port.nil?
+
+ port == requested_port
+ end
+
+ private
+
+ # Prior to ipaddr v1.2.3, if the allow list were the IPv4 to IPv6
+ # mapped address ::ffff:169.254.168.100 and the requested IP were
+ # 169.254.168.100 or ::ffff:169.254.168.100, the IP would be
+ # considered in the allow list. However, with
+ # https://github.com/ruby/ipaddr/pull/31, IPAddr#include? will
+ # only match if the IP versions are the same. This method
+ # preserves backwards compatibility if the versions differ by
+ # checking inclusion by coercing an IPv4 address to its IPv6
+ # mapped address.
+ def ip_include?(requested_ip)
+ return true if ip.include?(requested_ip)
+ return ip.include?(requested_ip.ipv4_mapped) if requested_ip.ipv4? && ip.ipv6?
+ return ip.ipv4_mapped.include?(requested_ip) if requested_ip.ipv6? && ip.ipv4?
+
+ false
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/net_http_adapter.rb b/gems/gitlab-http/lib/gitlab/http_v2/net_http_adapter.rb
new file mode 100644
index 00000000000..c6af2ed6aff
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/net_http_adapter.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'webmock' if Rails.env.test?
+require_relative 'buffered_io'
+
+module Gitlab
+ module HTTP_V2
+ # Webmock overwrites the Net::HTTP#request method with
+ # https://github.com/bblimke/webmock/blob/867f4b290fd133658aa9530cba4ba8b8c52c0d35/lib/webmock/http_lib_adapters/net_http.rb#L74
+ # Net::HTTP#request usually calls Net::HTTP#connect but the Webmock overwrite doesn't.
+ # This makes sure that, in a test environment, the superclass is the Webmock overwrite.
+ parent_class = if defined?(WebMock) && Rails.env.test?
+ WebMock::HttpLibAdapters::NetHttpAdapter.instance_variable_get(:@webMockNetHTTP)
+ else
+ Net::HTTP
+ end
+
+ class NetHttpAdapter < parent_class
+ private
+
+ def connect
+ result = super
+
+ @socket = BufferedIo.new(@socket.io,
+ read_timeout: @socket.read_timeout,
+ write_timeout: @socket.write_timeout,
+ continue_timeout: @socket.continue_timeout,
+ debug_output: @socket.debug_output)
+
+ result
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/new_connection_adapter.rb b/gems/gitlab-http/lib/gitlab/http_v2/new_connection_adapter.rb
new file mode 100644
index 00000000000..ee4be97dc6d
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/new_connection_adapter.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+# This class is part of the Gitlab::HTTP wrapper. It handles local requests and header timeouts
+#
+# 1. Local requests
+# Depending on the value of the global setting allow_local_requests_from_web_hooks_and_services,
+# this adapter will allow/block connection to internal IPs and/or urls.
+#
+# This functionality can be overridden by providing the setting the option
+# allow_local_requests = true in the request. For example:
+# Gitlab::HTTP.get('http://www.gitlab.com', allow_local_requests: true)
+#
+# This option will take precedence over the global setting.
+#
+# 2. Header timeouts
+# When the use_read_total_timeout option is used, that means the receiver
+# of the HTTP request cannot be trusted. Gitlab::BufferedIo will be used,
+# to read header data. It is a modified version of Net::BufferedIO that
+# raises a timeout error if reading header data takes too much time.
+
+require 'httparty'
+require_relative 'net_http_adapter'
+require_relative 'url_blocker'
+
+module Gitlab
+ module HTTP_V2
+ class NewConnectionAdapter < HTTParty::ConnectionAdapter
+ def initialize(...)
+ super
+
+ @allow_local_requests = options.delete(:allow_local_requests)
+ @extra_allowed_uris = options.delete(:extra_allowed_uris)
+ @deny_all_requests_except_allowed = options.delete(:deny_all_requests_except_allowed)
+ @outbound_local_requests_allowlist = options.delete(:outbound_local_requests_allowlist)
+ @dns_rebinding_protection_enabled = options.delete(:dns_rebinding_protection_enabled)
+ end
+
+ def connection
+ result = validate_url_with_proxy!(uri)
+ @uri = result.uri
+ hostname = result.hostname
+
+ http = super
+ http.hostname_override = hostname if hostname
+
+ unless result.use_proxy
+ http.proxy_from_env = false
+ http.proxy_address = nil
+ end
+
+ net_adapter = NetHttpAdapter.new(http.address, http.port)
+
+ http.instance_variables.each do |variable|
+ net_adapter.instance_variable_set(variable, http.instance_variable_get(variable))
+ end
+
+ net_adapter
+ end
+
+ private
+
+ def validate_url_with_proxy!(url)
+ UrlBlocker.validate_url_with_proxy!(url, **url_blocker_options)
+ rescue UrlBlocker::BlockedUrlError => e
+ raise HTTP_V2::BlockedUrlError, "URL is blocked: #{e.message}"
+ end
+
+ def url_blocker_options
+ {
+ allow_local_network: @allow_local_requests,
+ allow_localhost: @allow_local_requests,
+ extra_allowed_uris: @extra_allowed_uris,
+ schemes: %w[http https],
+ deny_all_requests_except_allowed: @deny_all_requests_except_allowed,
+ outbound_local_requests_allowlist: @outbound_local_requests_allowlist,
+ dns_rebind_protection: @dns_rebinding_protection_enabled
+ }.compact
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/patches.rb b/gems/gitlab-http/lib/gitlab/http_v2/patches.rb
new file mode 100644
index 00000000000..3d26fbc6447
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/patches.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require_relative "../../hostname_override_patch"
+require_relative "../../net_http/protocol_patch"
+require_relative "../../net_http/response_patch"
+require_relative "../../httparty/response_patch"
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/url_allowlist.rb b/gems/gitlab-http/lib/gitlab/http_v2/url_allowlist.rb
new file mode 100644
index 00000000000..6e17315c87d
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/url_allowlist.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'gitlab/utils/all'
+require_relative 'ip_allowlist_entry'
+require_relative 'domain_allowlist_entry'
+
+module Gitlab
+ module HTTP_V2
+ class UrlAllowlist
+ class << self
+ def ip_allowed?(ip_string, allowlist, port: nil)
+ return false if ip_string.blank?
+
+ ip_allowlist, _ = outbound_local_requests_allowlist_arrays(allowlist)
+ ip_obj = ::Gitlab::Utils.string_to_ip_object(ip_string)
+
+ ip_allowlist.any? do |ip_allowlist_entry|
+ ip_allowlist_entry.match?(ip_obj, port)
+ end
+ end
+
+ def domain_allowed?(domain_string, allowlist, port: nil)
+ return false if domain_string.blank?
+
+ _, domain_allowlist = outbound_local_requests_allowlist_arrays(allowlist)
+
+ domain_allowlist.any? do |domain_allowlist_entry|
+ domain_allowlist_entry.match?(domain_string, port)
+ end
+ end
+
+ private
+
+ def outbound_local_requests_allowlist_arrays(allowlist)
+ return [[], []] if allowlist.blank?
+
+ allowlist.reduce([[], []]) do |(ip_allowlist, domain_allowlist), string|
+ address, port = parse_addr_and_port(string)
+
+ ip_obj = ::Gitlab::Utils.string_to_ip_object(address)
+
+ if ip_obj
+ ip_allowlist << IpAllowlistEntry.new(ip_obj, port: port)
+ else
+ domain_allowlist << DomainAllowlistEntry.new(address, port: port)
+ end
+
+ [ip_allowlist, domain_allowlist]
+ end
+ end
+
+ def parse_addr_and_port(str)
+ case str
+ when /\A\[(?<address> .* )\]:(?<port> \d+ )\z/x # string like "[::1]:80"
+ address = $~[:address]
+ port = $~[:port]
+ when /\A(?<address> [^:]+ ):(?<port> \d+ )\z/x # string like "127.0.0.1:80"
+ address = $~[:address]
+ port = $~[:port]
+ else # string with no port number
+ address = str
+ port = nil
+ end
+
+ [address, port&.to_i]
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/url_blocker.rb b/gems/gitlab-http/lib/gitlab/http_v2/url_blocker.rb
new file mode 100644
index 00000000000..e15639dd60c
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/url_blocker.rb
@@ -0,0 +1,396 @@
+# frozen_string_literal: true
+
+require 'resolv'
+require 'ipaddress'
+require_relative 'url_allowlist'
+
+module Gitlab
+ module HTTP_V2
+ class UrlBlocker
+ BlockedUrlError = Class.new(StandardError)
+ HTTP_PROXY_ENV_VARS = %w[http_proxy https_proxy HTTP_PROXY HTTPS_PROXY].freeze
+
+ # Result stores the validation result:
+ # uri - The original URI requested
+ # hostname - The hostname that should be used to connect. For DNS
+ # rebinding protection, this will be the resolved IP address of
+ # the hostname.
+ # use_proxy -
+ # If true, this means that the proxy server specified in the
+ # http_proxy/https_proxy environment variables should be used.
+ #
+ # If false, this either means that no proxy server was specified
+ # or that the hostname in the URL is exempt via the no_proxy
+ # environment variable. This allows the caller to disable usage
+ # of a proxy since the IP address may be used to
+ # connect. Otherwise, Net::HTTP may erroneously compare the IP
+ # address against the no_proxy list.
+ Result = Struct.new(:uri, :hostname, :use_proxy)
+
+ class << self
+ # Validates the given url according to the constraints specified by arguments.
+ #
+ # ports - Raises error if the given URL port is not between given ports.
+ # allow_localhost - Raises error if URL resolves to a localhost IP address and argument is false.
+ # allow_local_network - Raises error if URL resolves to a link-local address and argument is false.
+ # extra_allowed_uris - Array of URI objects that are allowed in addition to hostname and IP constraints.
+ # This parameter is passed in this class when making the HTTP request.
+ # ascii_only - Raises error if URL has unicode characters and argument is true.
+ # enforce_user - Raises error if URL user doesn't start with alphanumeric characters and argument is true.
+ # enforce_sanitization - Raises error if URL includes any HTML/CSS/JS tags and argument is true.
+ # deny_all_requests_except_allowed - Raises error if URL is not in the allow list and argument is true. Can be Boolean or Proc. Defaults to instance app setting.
+ # dns_rebind_protection - Enforce DNS-rebinding attack protection.
+ # outbound_local_requests_allowlist - A list of trusted domains or IP addresses to which local requests are
+ # allowed when local requests for webhooks and integrations are disabled. This parameter is static and
+ # comes from the `outbound_local_requests_whitelist` application setting.
+ #
+ # Returns a Result object.
+ # rubocop:disable Metrics/ParameterLists
+ def validate_url_with_proxy!(
+ url,
+ schemes:,
+ ports: [],
+ allow_localhost: false,
+ allow_local_network: true,
+ extra_allowed_uris: [],
+ ascii_only: false,
+ enforce_user: false,
+ enforce_sanitization: false,
+ deny_all_requests_except_allowed: false,
+ dns_rebind_protection: true,
+ outbound_local_requests_allowlist: []
+ )
+ # rubocop:enable Metrics/ParameterLists
+
+ return Result.new(nil, nil, true) if url.nil?
+
+ raise ArgumentError, 'The schemes is a required argument' if schemes.blank?
+
+ # Param url can be a string, URI or Addressable::URI
+ uri = parse_url(url)
+
+ validate_uri(
+ uri: uri,
+ schemes: schemes,
+ ports: ports,
+ enforce_sanitization: enforce_sanitization,
+ enforce_user: enforce_user,
+ ascii_only: ascii_only
+ )
+
+ begin
+ address_info = get_address_info(uri)
+ rescue SocketError
+ proxy_in_use = uri_under_proxy_setting?(uri, nil)
+
+ return Result.new(uri, nil, proxy_in_use) unless enforce_address_info_retrievable?(uri, dns_rebind_protection, deny_all_requests_except_allowed, outbound_local_requests_allowlist)
+
+ raise BlockedUrlError, 'Host cannot be resolved or invalid'
+ end
+
+ ip_address = ip_address(address_info)
+ proxy_in_use = uri_under_proxy_setting?(uri, ip_address)
+
+ # Ignore DNS rebind protection when a proxy is being used, as DNS
+ # rebinding is expected behavior.
+ dns_rebind_protection &&= !proxy_in_use
+ return Result.new(uri, nil, proxy_in_use) if domain_in_allow_list?(uri, outbound_local_requests_allowlist)
+
+ protected_uri_with_hostname = enforce_uri_hostname(ip_address, uri, dns_rebind_protection, proxy_in_use)
+
+ return protected_uri_with_hostname if ip_in_allow_list?(ip_address, outbound_local_requests_allowlist, port: get_port(uri))
+
+ return protected_uri_with_hostname if allowed_uri?(uri, extra_allowed_uris)
+
+ validate_deny_all_requests_except_allowed!(deny_all_requests_except_allowed)
+
+ validate_local_request(
+ address_info: address_info,
+ allow_localhost: allow_localhost,
+ allow_local_network: allow_local_network
+ )
+
+ protected_uri_with_hostname
+ end
+
+ def blocked_url?(url, **kwargs)
+ validate!(url, **kwargs)
+
+ false
+ rescue BlockedUrlError
+ true
+ end
+
+ # For backwards compatibility, Returns an array with [<uri>, <original-hostname>].
+ # Issue for refactoring: https://gitlab.com/gitlab-org/gitlab/-/issues/410890
+ def validate!(...)
+ result = validate_url_with_proxy!(...)
+ [result.uri, result.hostname]
+ end
+
+ private
+
+ # Returns the given URI with IP address as hostname and the original hostname respectively
+ # in an Array.
+ #
+ # It checks whether the resolved IP address matches with the hostname. If not, it changes
+ # the hostname to the resolved IP address.
+ #
+ # The original hostname is used to validate the SSL, given in that scenario
+ # we'll be making the request to the IP address, instead of using the hostname.
+ def enforce_uri_hostname(ip_address, uri, dns_rebind_protection, proxy_in_use)
+ return Result.new(uri, nil, proxy_in_use) unless dns_rebind_protection && ip_address && ip_address != uri.hostname
+
+ new_uri = uri.dup
+ new_uri.hostname = ip_address
+ Result.new(new_uri, uri.hostname, proxy_in_use)
+ end
+
+ def ip_address(address_info)
+ address_info.first&.ip_address
+ end
+
+ def validate_uri(uri:, schemes:, ports:, enforce_sanitization:, enforce_user:, ascii_only:)
+ validate_html_tags(uri) if enforce_sanitization
+
+ return if internal?(uri)
+
+ validate_scheme(uri.scheme, schemes)
+ validate_port(get_port(uri), ports) if ports.any?
+ validate_user(uri.user) if enforce_user
+ validate_hostname(uri.hostname)
+ validate_unicode_restriction(uri) if ascii_only
+ end
+
+ def uri_under_proxy_setting?(uri, ip_address)
+ return false unless http_proxy_env?
+ # `no_proxy|NO_PROXY` specifies addresses for which the proxy is not
+ # used. If it's empty, there are no exceptions and this URI
+ # will be under proxy settings.
+ return true if no_proxy_env.blank?
+
+ # `no_proxy|NO_PROXY` is being used. We must check whether it
+ # applies to this specific URI.
+ ::URI::Generic.use_proxy?(uri.hostname, ip_address, get_port(uri), no_proxy_env)
+ end
+
+ # Returns addrinfo object for the URI.
+ #
+ # @param uri [Addressable::URI]
+ #
+ # @raise [Gitlab::UrlBlocker::BlockedUrlError, ArgumentError] - BlockedUrlError raised if host is too long.
+ #
+ # @return [Array<Addrinfo>]
+ def get_address_info(uri)
+ Addrinfo.getaddrinfo(uri.hostname, get_port(uri), nil, :STREAM).map do |addr|
+ addr.ipv6_v4mapped? ? addr.ipv6_to_ipv4 : addr
+ end
+ rescue ArgumentError => error
+ # Addrinfo.getaddrinfo errors if the domain exceeds 1024 characters.
+ raise unless error.message.include?('hostname too long')
+
+ raise BlockedUrlError, "Host is too long (maximum is 1024 characters)"
+ end
+
+ def enforce_address_info_retrievable?(uri, dns_rebind_protection, deny_all_requests_except_allowed, outbound_local_requests_allowlist)
+ # Do not enforce if URI is in the allow list
+ return false if domain_in_allow_list?(uri, outbound_local_requests_allowlist)
+
+ # Enforce if the instance should block requests
+ return true if deny_all_requests_except_allowed?(deny_all_requests_except_allowed)
+
+ # Do not enforce if DNS rebinding protection is disabled
+ return false unless dns_rebind_protection
+
+ # Do not enforce if proxy is used
+ return false if http_proxy_env?
+
+ # In the test suite we use a lot of mocked urls that are either invalid or
+ # don't exist. In order to avoid modifying a ton of tests and factories
+ # we allow invalid urls unless the environment variable RSPEC_ALLOW_INVALID_URLS
+ # is not true
+ return false if Rails.env.test? && ENV['RSPEC_ALLOW_INVALID_URLS'] == 'true'
+
+ true
+ end
+
+ def validate_local_request(
+ address_info:,
+ allow_localhost:,
+ allow_local_network:)
+ return if allow_local_network && allow_localhost
+
+ unless allow_localhost
+ validate_localhost(address_info)
+ validate_loopback(address_info)
+ end
+
+ unless allow_local_network
+ validate_local_network(address_info)
+ validate_link_local(address_info)
+ validate_shared_address(address_info)
+ validate_limited_broadcast_address(address_info)
+ end
+ end
+
+ def validate_shared_address(addrs_info)
+ netmask = IPAddr.new('100.64.0.0/10')
+ return unless addrs_info.any? { |addr| netmask.include?(addr.ip_address) }
+
+ raise BlockedUrlError, "Requests to the shared address space are not allowed"
+ end
+
+ def validate_html_tags(uri)
+ uri_str = uri.to_s
+ sanitized_uri = ActionController::Base.helpers.sanitize(uri_str, tags: [])
+ if sanitized_uri != uri_str
+ raise BlockedUrlError, 'HTML/CSS/JS tags are not allowed'
+ end
+ end
+
+ def parse_url(url)
+ Addressable::URI.parse(url).tap do |parsed_url|
+ raise Addressable::URI::InvalidURIError if multiline_blocked?(parsed_url)
+ end
+ rescue Addressable::URI::InvalidURIError, URI::InvalidURIError
+ raise BlockedUrlError, 'URI is invalid'
+ end
+
+ def multiline_blocked?(parsed_url)
+ url = parsed_url.to_s
+
+ return true if url =~ /\n|\r/
+ # Google Cloud Storage uses a multi-line, encoded Signature query string
+ return false if %w[http https].include?(parsed_url.scheme&.downcase)
+
+ CGI.unescape(url) =~ /\n|\r/
+ end
+
+ def validate_port(port, ports)
+ return if port.blank?
+ # Only ports under 1024 are restricted
+ return if port >= 1024
+ return if ports.include?(port)
+
+ raise BlockedUrlError, "Only allowed ports are #{ports.join(', ')}, and any over 1024"
+ end
+
+ def validate_scheme(scheme, schemes)
+ if scheme.blank? || (schemes.any? && schemes.exclude?(scheme))
+ raise BlockedUrlError, "Only allowed schemes are #{schemes.join(', ')}"
+ end
+ end
+
+ def validate_user(value)
+ return if value.blank?
+ return if value =~ /\A\p{Alnum}/
+
+ raise BlockedUrlError, "Username needs to start with an alphanumeric character"
+ end
+
+ def validate_hostname(value)
+ return if value.blank?
+ return if IPAddress.valid?(value)
+ return if value =~ /\A\p{Alnum}/
+
+ raise BlockedUrlError, "Hostname or IP address invalid"
+ end
+
+ def validate_unicode_restriction(uri)
+ return if uri.to_s.ascii_only?
+
+ raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}"
+ end
+
+ def validate_localhost(addrs_info)
+ local_ips = ["::", "0.0.0.0"]
+ local_ips.concat(Socket.ip_address_list.map(&:ip_address))
+
+ return if (local_ips & addrs_info.map(&:ip_address)).empty?
+
+ raise BlockedUrlError, "Requests to localhost are not allowed"
+ end
+
+ def validate_loopback(addrs_info)
+ return unless addrs_info.any? { |addr| addr.ipv4_loopback? || addr.ipv6_loopback? }
+
+ raise BlockedUrlError, "Requests to loopback addresses are not allowed"
+ end
+
+ def validate_local_network(addrs_info)
+ return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? || addr.ipv6_unique_local? }
+
+ raise BlockedUrlError, "Requests to the local network are not allowed"
+ end
+
+ def validate_link_local(addrs_info)
+ netmask = IPAddr.new('169.254.0.0/16')
+ return unless addrs_info.any? { |addr| addr.ipv6_linklocal? || netmask.include?(addr.ip_address) }
+
+ raise BlockedUrlError, "Requests to the link local network are not allowed"
+ end
+
+ # Raises a BlockedUrlError if the instance is configured to deny all requests.
+ #
+ # This should only be called after allow list checks have been made.
+ def validate_deny_all_requests_except_allowed!(should_deny)
+ return unless deny_all_requests_except_allowed?(should_deny)
+
+ raise BlockedUrlError, "Requests to hosts and IP addresses not on the Allow List are denied"
+ end
+
+ # Raises a BlockedUrlError if any IP in `addrs_info` is the limited
+ # broadcast address.
+ # https://datatracker.ietf.org/doc/html/rfc919#section-7
+ def validate_limited_broadcast_address(addrs_info)
+ blocked_ips = ["255.255.255.255"]
+
+ return if (blocked_ips & addrs_info.map(&:ip_address)).empty?
+
+ raise BlockedUrlError, "Requests to the limited broadcast address are not allowed"
+ end
+
+ def allowed_uri?(uri, extra_allowed_uris)
+ internal?(uri) || check_uri(uri, extra_allowed_uris)
+ end
+
+ # Allow url from the GitLab instance itself but only for the configured hostname and ports
+ def internal?(uri)
+ check_uri(uri, Gitlab::HTTP_V2.configuration.allowed_internal_uris)
+ end
+
+ def check_uri(uri, allowlist)
+ allowlist.any? do |allowed_uri|
+ allowed_uri.scheme == uri.scheme &&
+ allowed_uri.hostname == uri.hostname &&
+ get_port(allowed_uri) == get_port(uri)
+ end
+ end
+
+ def deny_all_requests_except_allowed?(should_deny)
+ should_deny.is_a?(Proc) ? should_deny.call : should_deny
+ end
+
+ def domain_in_allow_list?(uri, outbound_local_requests_allowlist)
+ Gitlab::HTTP_V2::UrlAllowlist.domain_allowed?(uri.normalized_host, outbound_local_requests_allowlist, port: get_port(uri))
+ end
+
+ def ip_in_allow_list?(ip_address, outbound_local_requests_allowlist, port: nil)
+ Gitlab::HTTP_V2::UrlAllowlist.ip_allowed?(ip_address, outbound_local_requests_allowlist, port: port)
+ end
+
+ def no_proxy_env
+ ENV['no_proxy'] || ENV['NO_PROXY']
+ end
+
+ def http_proxy_env?
+ HTTP_PROXY_ENV_VARS.any? { |name| ENV[name].present? }
+ end
+
+ def get_port(uri)
+ uri.port || uri.default_port
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/gitlab/http_v2/version.rb b/gems/gitlab-http/lib/gitlab/http_v2/version.rb
new file mode 100644
index 00000000000..8a9a17de112
--- /dev/null
+++ b/gems/gitlab-http/lib/gitlab/http_v2/version.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module HTTP_V2
+ module Version
+ VERSION = "0.1.0"
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/hostname_override_patch.rb b/gems/gitlab-http/lib/hostname_override_patch.rb
new file mode 100644
index 00000000000..c5799bf0682
--- /dev/null
+++ b/gems/gitlab-http/lib/hostname_override_patch.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# This override allows passing `@hostname_override` to the SNI protocol,
+# which is used to lookup the correct SSL certificate in the
+# request handshake process.
+#
+# Given we've forced the HTTP request to be sent to the resolved
+# IP address in a few scenarios (e.g.: `Gitlab::HTTP_V2` through
+# `UrlBlocker.validate!`), we need to provide the _original_
+# hostname via SNI in order to have a clean connection setup.
+#
+# This is ultimately needed in order to avoid DNS rebinding attacks
+# through HTTP requests.
+
+require 'net/http'
+
+class OpenSSL::SSL::SSLContext
+ attr_accessor :hostname_override
+end
+
+class OpenSSL::SSL::SSLSocket
+ module HostnameOverride
+ # rubocop: disable Gitlab/ModuleWithInstanceVariables
+ def hostname=(hostname)
+ super(@context.hostname_override || hostname)
+ end
+
+ def post_connection_check(hostname)
+ super(@context.hostname_override || hostname)
+ end
+ # rubocop: enable Gitlab/ModuleWithInstanceVariables
+ end
+
+ prepend HostnameOverride
+end
+
+class Net::HTTP
+ attr_accessor :hostname_override
+
+ SSL_IVNAMES << :@hostname_override
+ SSL_ATTRIBUTES << :hostname_override
+
+ module HostnameOverride
+ def addr_port
+ return super unless hostname_override
+
+ addr = hostname_override
+ default_port = use_ssl? ? Net::HTTP.https_default_port : Net::HTTP.http_default_port
+ default_port == port ? addr : "#{addr}:#{port}"
+ end
+ end
+
+ prepend HostnameOverride
+end
diff --git a/gems/gitlab-http/lib/httparty/response_patch.rb b/gems/gitlab-http/lib/httparty/response_patch.rb
new file mode 100644
index 00000000000..3488ff034b4
--- /dev/null
+++ b/gems/gitlab-http/lib/httparty/response_patch.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'httparty'
+
+HTTParty::Response.class_eval do
+ # Original method: https://github.com/jnunemaker/httparty/blob/v0.20.0/lib/httparty/response.rb#L83-L86
+ # Related issue: https://github.com/jnunemaker/httparty/issues/568
+ #
+ # We need to override this method because `Concurrent::Promise` calls `nil?` on the response when
+ # calling the `value` method. And the `value` calls `nil?`.
+ # https://github.com/ruby-concurrency/concurrent-ruby/blob/v1.2.2/lib/concurrent-ruby/concurrent/concern/dereferenceable.rb#L64
+ def nil?
+ response.nil? || response.body.blank?
+ end
+end
diff --git a/gems/gitlab-http/lib/net_http/protocol_patch.rb b/gems/gitlab-http/lib/net_http/protocol_patch.rb
new file mode 100644
index 00000000000..8231423e1a5
--- /dev/null
+++ b/gems/gitlab-http/lib/net_http/protocol_patch.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# Monkey patch Net::HTTP to fix missing URL decoding for username and password in proxy settings
+#
+# See proposed upstream fix https://github.com/ruby/net-http/pull/5
+# See Ruby-lang issue https://bugs.ruby-lang.org/issues/17542
+# See issue on GitLab https://gitlab.com/gitlab-org/gitlab/-/issues/289836
+
+require 'net/http'
+
+# This file can be removed once Ruby 3.0 is no longer supported:
+# https://gitlab.com/gitlab-org/gitlab/-/issues/396223
+return if Gem::Version.new(Net::HTTP::VERSION) >= Gem::Version.new('0.2.0')
+
+module Net
+ class HTTP < Protocol
+ def proxy_user
+ if environment_variable_is_multiuser_safe? && @proxy_from_env
+ user = proxy_uri&.user
+ CGI.unescape(user) unless user.nil?
+ else
+ @proxy_user
+ end
+ end
+
+ def proxy_pass
+ if environment_variable_is_multiuser_safe? && @proxy_from_env
+ pass = proxy_uri&.password
+ CGI.unescape(pass) unless pass.nil?
+ else
+ @proxy_pass
+ end
+ end
+
+ def environment_variable_is_multiuser_safe?
+ ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE
+ end
+ end
+end
diff --git a/gems/gitlab-http/lib/net_http/response_patch.rb b/gems/gitlab-http/lib/net_http/response_patch.rb
new file mode 100644
index 00000000000..7edd518f4b9
--- /dev/null
+++ b/gems/gitlab-http/lib/net_http/response_patch.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Net
+ class HTTPResponse
+ # rubocop: disable Cop/LineBreakAfterGuardClauses
+ # rubocop: disable Cop/LineBreakAroundConditionalBlock
+ # rubocop: disable Layout/EmptyLineAfterGuardClause
+ # rubocop: disable Style/AndOr
+ # rubocop: disable Style/CharacterLiteral
+ # rubocop: disable Style/InfiniteLoop
+
+ # Original method:
+ # https://github.com/ruby/ruby/blob/v2_7_5/lib/net/http/response.rb#L54-L69
+ #
+ # Our changes:
+ # - Pass along the `start_time` to `Gitlab::HTTP_V2::BufferedIo`, so we can raise a timeout
+ # if reading the headers takes too long.
+ # - Limit the regexes to avoid ReDoS attacks.
+ def self.each_response_header(sock)
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ key = value = nil
+ while true
+ line = sock.is_a?(Gitlab::HTTP_V2::BufferedIo) ? sock.readuntil("\n", true, start_time) : sock.readuntil("\n", true)
+ line = line.sub(/\s{0,10}\z/, '')
+ break if line.empty?
+ if line[0] == ?\s or line[0] == ?\t and value
+ # rubocop:disable Gitlab/NoCodeCoverageComment
+ # :nocov:
+ value << ' ' unless value.empty?
+ value << line.strip
+ # :nocov:
+ # rubocop:enable Gitlab/NoCodeCoverageComment
+ else
+ yield key, value if key
+ key, value = line.strip.split(/\s{0,10}:\s{0,10}/, 2)
+ raise Net::HTTPBadResponse, 'wrong header line format' if value.nil?
+ end
+ end
+ yield key, value if key
+ end
+ # rubocop: enable Cop/LineBreakAfterGuardClauses
+ # rubocop: enable Cop/LineBreakAroundConditionalBlock
+ # rubocop: enable Layout/EmptyLineAfterGuardClause
+ # rubocop: enable Style/AndOr
+ # rubocop: enable Style/CharacterLiteral
+ # rubocop: enable Style/InfiniteLoop
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/buffered_io_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/buffered_io_spec.rb
new file mode 100644
index 00000000000..856589a4806
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/buffered_io_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2::BufferedIo do
+ describe '#readuntil' do
+ let(:mock_io) { StringIO.new('a') }
+ let(:start_time) { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
+
+ before do
+ stub_const('Gitlab::HTTP_V2::BufferedIo::HEADER_READ_TIMEOUT', 0.1)
+ end
+
+ subject(:readuntil) do
+ described_class.new(mock_io).readuntil('a', false, start_time)
+ end
+
+ it 'does not raise a timeout error' do
+ expect { readuntil }.not_to raise_error
+ end
+
+ context 'when the response contains infinitely long headers' do
+ before do
+ read_counter = 0
+
+ allow(mock_io).to receive(:read_nonblock) do |buffer_size, *_|
+ read_counter += 1
+ raise 'Test did not raise HeaderReadTimeout' if read_counter > 10
+
+ sleep 0.01
+ 'H' * buffer_size
+ end
+ end
+
+ it 'raises a timeout error' do
+ expect { readuntil }.to raise_error(Gitlab::HTTP_V2::HeaderReadTimeout, /Request timed out after reading headers for 0\.[0-9]+ seconds/)
+ end
+
+ context 'when not passing start_time' do
+ subject(:readuntil) do
+ described_class.new(mock_io).readuntil('a', false)
+ end
+
+ it 'raises a timeout error' do
+ expect { readuntil }.to raise_error(Gitlab::HTTP_V2::HeaderReadTimeout, /Request timed out after reading headers for 0\.[0-9]+ seconds/)
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/domain_allowlist_entry_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/domain_allowlist_entry_spec.rb
new file mode 100644
index 00000000000..0f9d5bc550d
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/domain_allowlist_entry_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2::DomainAllowlistEntry do
+ let(:domain) { 'www.example.com' }
+
+ describe '#initialize' do
+ it 'initializes without port' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry.domain).to eq(domain)
+ expect(domain_allowlist_entry.port).to be(nil)
+ end
+
+ it 'initializes with port' do
+ port = 8080
+ domain_allowlist_entry = described_class.new(domain, port: port)
+
+ expect(domain_allowlist_entry.domain).to eq(domain)
+ expect(domain_allowlist_entry.port).to eq(port)
+ end
+ end
+
+ describe '#match?' do
+ it 'matches when domain and port are equal' do
+ port = 8080
+ domain_allowlist_entry = described_class.new(domain, port: port)
+
+ expect(domain_allowlist_entry).to be_match(domain, port)
+ end
+
+ it 'matches any port when port is nil' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry).to be_match(domain, 8080)
+ expect(domain_allowlist_entry).to be_match(domain, 9090)
+ end
+
+ it 'does not match when port is present but requested_port is nil' do
+ domain_allowlist_entry = described_class.new(domain, port: 8080)
+
+ expect(domain_allowlist_entry).not_to be_match(domain, nil)
+ end
+
+ it 'matches when port and requested_port are nil' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry).to be_match(domain)
+ end
+
+ it 'does not match if domain is not equal' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry).not_to be_match('www.gitlab.com', 8080)
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/http_connection_adapter_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/http_connection_adapter_spec.rb
new file mode 100644
index 00000000000..852bafc5557
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/http_connection_adapter_spec.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2::NewConnectionAdapter, feature_category: :shared do
+ let(:uri) { URI('https://example.org') }
+ let(:options) { {} }
+
+ subject(:connection) { described_class.new(uri, options).connection }
+
+ describe '#connection' do
+ before do
+ stub_all_dns('https://example.org', ip_address: '93.184.216.34')
+ end
+
+ context 'when local requests are allowed' do
+ let(:options) { { allow_local_requests: true } }
+
+ it 'sets up the connection' do
+ expect(connection).to be_a(Gitlab::HTTP_V2::NetHttpAdapter)
+ expect(connection.address).to eq('93.184.216.34')
+ expect(connection.hostname_override).to eq('example.org')
+ expect(connection.addr_port).to eq('example.org')
+ expect(connection.port).to eq(443)
+ end
+ end
+
+ context 'when local requests are not allowed' do
+ let(:options) { { allow_local_requests: false } }
+
+ it 'sets up the connection' do
+ expect(connection).to be_a(Gitlab::HTTP_V2::NetHttpAdapter)
+ expect(connection.address).to eq('93.184.216.34')
+ expect(connection.hostname_override).to eq('example.org')
+ expect(connection.addr_port).to eq('example.org')
+ expect(connection.port).to eq(443)
+ end
+
+ context 'when it is a request to local network' do
+ let(:uri) { URI('http://172.16.0.0/12') }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP_V2::BlockedUrlError,
+ "URL is blocked: Requests to the local network are not allowed"
+ )
+ end
+
+ context 'when local request allowed' do
+ let(:options) { { allow_local_requests: true } }
+
+ it 'sets up the connection' do
+ expect(connection).to be_a(Gitlab::HTTP_V2::NetHttpAdapter)
+ expect(connection.address).to eq('172.16.0.0')
+ expect(connection.hostname_override).to be(nil)
+ expect(connection.addr_port).to eq('172.16.0.0')
+ expect(connection.port).to eq(80)
+ end
+ end
+ end
+
+ context 'when it is a request to local address' do
+ let(:uri) { URI('http://127.0.0.1') }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP_V2::BlockedUrlError,
+ "URL is blocked: Requests to localhost are not allowed"
+ )
+ end
+
+ context 'when local request allowed' do
+ let(:options) { { allow_local_requests: true } }
+
+ it 'sets up the connection' do
+ expect(connection).to be_a(Gitlab::HTTP_V2::NetHttpAdapter)
+ expect(connection.address).to eq('127.0.0.1')
+ expect(connection.hostname_override).to be(nil)
+ expect(connection.addr_port).to eq('127.0.0.1')
+ expect(connection.port).to eq(80)
+ end
+ end
+ end
+
+ context 'when port different from URL scheme is used' do
+ let(:uri) { URI('https://example.org:8080') }
+
+ it 'sets up the addr_port accordingly' do
+ expect(connection).to be_a(Gitlab::HTTP_V2::NetHttpAdapter)
+ expect(connection.address).to eq('93.184.216.34')
+ expect(connection.hostname_override).to eq('example.org')
+ expect(connection.addr_port).to eq('example.org:8080')
+ expect(connection.port).to eq(8080)
+ end
+ end
+ end
+
+ context 'when DNS rebinding protection is disabled' do
+ let(:options) { { dns_rebinding_protection_enabled: false } }
+
+ it 'sets up the connection' do
+ expect(connection).to be_a(Gitlab::HTTP_V2::NetHttpAdapter)
+ expect(connection.address).to eq('example.org')
+ expect(connection.hostname_override).to eq(nil)
+ expect(connection.addr_port).to eq('example.org')
+ expect(connection.port).to eq(443)
+ end
+ end
+
+ context 'when proxy is enabled' do
+ before do
+ stub_env('http_proxy', 'http://proxy.example.com')
+ end
+
+ it 'proxy stays configured' do
+ expect(connection.proxy?).to be true
+ expect(connection.proxy_from_env?).to be true
+ expect(connection.proxy_address).to eq('proxy.example.com')
+ end
+
+ context 'when no_proxy matches the request' do
+ before do
+ stub_env('no_proxy', 'example.org')
+ end
+
+ it 'proxy is disabled' do
+ expect(connection.proxy?).to be false
+ expect(connection.proxy_from_env?).to be false
+ expect(connection.proxy_address).to be nil
+ end
+ end
+
+ context 'when no_proxy does not match the request' do
+ before do
+ stub_env('no_proxy', 'example.com')
+ end
+
+ it 'proxy stays configured' do
+ expect(connection.proxy?).to be true
+ expect(connection.proxy_from_env?).to be true
+ expect(connection.proxy_address).to eq('proxy.example.com')
+ end
+ end
+ end
+
+ context 'when URL scheme is not HTTP/HTTPS' do
+ let(:uri) { URI('ssh://example.org') }
+
+ it 'raises error' do
+ expect { subject }.to raise_error(
+ Gitlab::HTTP_V2::BlockedUrlError,
+ "URL is blocked: Only allowed schemes are http, https"
+ )
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/ip_allowlist_entry_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/ip_allowlist_entry_spec.rb
new file mode 100644
index 00000000000..ad7d993ec62
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/ip_allowlist_entry_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2::IpAllowlistEntry, feature_category: :shared do
+ let(:ipv4) { IPAddr.new('192.168.1.1') }
+
+ describe '#initialize' do
+ it 'initializes without port' do
+ ip_allowlist_entry = described_class.new(ipv4)
+
+ expect(ip_allowlist_entry.ip).to eq(ipv4)
+ expect(ip_allowlist_entry.port).to be(nil)
+ end
+
+ it 'initializes with port' do
+ port = 8080
+ ip_allowlist_entry = described_class.new(ipv4, port: port)
+
+ expect(ip_allowlist_entry.ip).to eq(ipv4)
+ expect(ip_allowlist_entry.port).to eq(port)
+ end
+ end
+
+ describe '#match?' do
+ it 'matches with equivalent IP and port' do
+ port = 8080
+ ip_allowlist_entry = described_class.new(ipv4, port: port)
+
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s, port)
+ end
+
+ it 'matches any port when port is nil' do
+ ip_allowlist_entry = described_class.new(ipv4)
+
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s, 8080)
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s, 9090)
+ end
+
+ it 'does not match when port is present but requested_port is nil' do
+ ip_allowlist_entry = described_class.new(ipv4, port: 8080)
+
+ expect(ip_allowlist_entry).not_to be_match(ipv4.to_s, nil)
+ end
+
+ it 'matches when port and requested_port are nil' do
+ ip_allowlist_entry = described_class.new(ipv4)
+
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s)
+ end
+
+ it 'works with ipv6' do
+ ipv6 = IPAddr.new('fe80::c800:eff:fe74:8')
+ ip_allowlist_entry = described_class.new(ipv6)
+
+ expect(ip_allowlist_entry).to be_match(ipv6.to_s, 8080)
+ end
+
+ it 'matches ipv4 within IPv4 range' do
+ ipv4_range = IPAddr.new('127.0.0.0/28')
+ ip_allowlist_entry = described_class.new(ipv4_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv4_range.to_range.last.to_s, 8080)
+ expect(ip_allowlist_entry).not_to be_match('127.0.1.1', 8080)
+ end
+
+ it 'matches IPv6 within IPv6 range' do
+ ipv6_range = IPAddr.new('::ffff:192.168.1.0/8')
+ ip_allowlist_entry = described_class.new(ipv6_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv6_range.to_range.last.to_s, 8080)
+ expect(ip_allowlist_entry).not_to be_match('fd84:6d02:f6d8:f::f', 8080)
+ end
+
+ it 'matches IPv4 to IPv6 mapped addresses in allow list' do
+ ipv6_range = IPAddr.new('::ffff:192.168.1.1')
+ ip_allowlist_entry = described_class.new(ipv6_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv4, 8080)
+ expect(ip_allowlist_entry).to be_match(ipv6_range.to_range.last.to_s, 8080)
+ expect(ip_allowlist_entry).not_to be_match('::ffff:192.168.1.0', 8080)
+ expect(ip_allowlist_entry).not_to be_match('::ffff:169.254.168.101', 8080)
+ end
+
+ it 'matches IPv4 to IPv6 mapped addresses in requested IP' do
+ ipv4_range = IPAddr.new('192.168.1.1/24')
+ ip_allowlist_entry = described_class.new(ipv4_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv4, 8080)
+ expect(ip_allowlist_entry).to be_match('::ffff:192.168.1.0', 8080)
+ expect(ip_allowlist_entry).to be_match('::ffff:192.168.1.1', 8080)
+ expect(ip_allowlist_entry).not_to be_match('::ffff:169.254.170.100/8', 8080)
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/net_http_adapter_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/net_http_adapter_spec.rb
new file mode 100644
index 00000000000..22998803cc8
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/net_http_adapter_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'net/http'
+
+RSpec.describe Gitlab::HTTP_V2::NetHttpAdapter, feature_category: :api do
+ describe '#connect' do
+ let(:url) { 'https://example.org' }
+ let(:net_http_adapter) { described_class.new(url) }
+
+ subject(:connect) { net_http_adapter.send(:connect) }
+
+ before do
+ allow(TCPSocket).to receive(:open).and_return(Socket.new(:INET, :STREAM))
+ end
+
+ it 'uses a Gitlab::HTTP_V2::BufferedIo instance as @socket' do
+ connect
+
+ expect(net_http_adapter.instance_variable_get(:@socket)).to be_a(Gitlab::HTTP_V2::BufferedIo)
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb
new file mode 100644
index 00000000000..b82646fb365
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/net_http_patch_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'net/http'
+
+RSpec.describe 'Net::HTTP patch proxy user and password encoding' do
+ let(:net_http) { Net::HTTP.new('hostname.example') }
+
+ before do
+ # This file can be removed once Ruby 3.0 is no longer supported:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/396223
+ skip if Gem::Version.new(Net::HTTP::VERSION) >= Gem::Version.new('0.2.0')
+ end
+
+ describe '#proxy_user' do
+ subject { net_http.proxy_user }
+
+ it { is_expected.to eq(nil) }
+
+ context 'with http_proxy env' do
+ let(:http_proxy) { 'http://proxy.example:8000' }
+
+ before do
+ stub_env('http_proxy', http_proxy)
+ end
+
+ it { is_expected.to eq(nil) }
+
+ context 'and user:password authentication' do
+ let(:http_proxy) { 'http://Y%5CX:R%25S%5D%20%3FX@proxy.example:8000' }
+
+ context 'when on multiuser safe platform' do
+ # linux, freebsd, darwin are considered multi user safe platforms
+ # See https://github.com/ruby/net-http/blob/v0.1.1/lib/net/http.rb#L1174-L1178
+
+ before do
+ allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(true)
+ end
+
+ it { is_expected.to eq 'Y\\X' }
+ end
+
+ context 'when not on multiuser safe platform' do
+ before do
+ allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+ end
+
+ describe '#proxy_pass' do
+ subject { net_http.proxy_pass }
+
+ it { is_expected.to eq(nil) }
+
+ context 'with http_proxy env' do
+ let(:http_proxy) { 'http://proxy.example:8000' }
+
+ before do
+ stub_env('http_proxy', http_proxy)
+ end
+
+ it { is_expected.to eq(nil) }
+
+ context 'and user:password authentication' do
+ let(:http_proxy) { 'http://Y%5CX:R%25S%5D%20%3FX@proxy.example:8000' }
+
+ context 'when on multiuser safe platform' do
+ # linux, freebsd, darwin are considered multi user safe platforms
+ # See https://github.com/ruby/net-http/blob/v0.1.1/lib/net/http.rb#L1174-L1178
+
+ before do
+ allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(true)
+ end
+
+ it { is_expected.to eq 'R%S] ?X' }
+ end
+
+ context 'when not on multiuser safe platform' do
+ before do
+ allow(net_http).to receive(:environment_variable_is_multiuser_safe?).and_return(false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/net_http_response_patch_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/net_http_response_patch_spec.rb
new file mode 100644
index 00000000000..f8d0f0a57fc
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/net_http_response_patch_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Net::HTTPResponse patch header read timeout', feature_category: :shared do
+ describe '.each_response_header' do
+ let(:server_response) do
+ <<~HTTP
+ Content-Type: text/html
+ Header-Two: foo
+
+ Hello World
+ HTTP
+ end
+
+ before do
+ stub_const('Gitlab::HTTP_V2::BufferedIo::HEADER_READ_TIMEOUT', 0.1)
+ end
+
+ subject(:each_response_header) { Net::HTTPResponse.each_response_header(socket) { |k, v| } } # rubocop:disable Lint/EmptyBlock
+
+ context 'with Net::BufferedIO' do
+ let(:socket) { Net::BufferedIO.new(StringIO.new(server_response)) }
+
+ it 'does not forward start time to the socket' do
+ allow(socket).to receive(:readuntil).and_call_original
+ expect(socket).to receive(:readuntil).with("\n", true)
+
+ each_response_header
+ end
+
+ context 'when the response contains many consecutive spaces' do
+ it 'has no regex backtracking issues' do
+ expect(socket).to receive(:readuntil).and_return(
+ "a: #{' ' * 100_000} b",
+ ''
+ )
+
+ Timeout.timeout(1) do
+ each_response_header
+ end
+ end
+ end
+ end
+
+ context 'with Gitlab:HTTP_V2:::BufferedIo' do
+ let(:mock_io) { StringIO.new(server_response) }
+ let(:socket) { Gitlab::HTTP_V2::BufferedIo.new(mock_io) }
+
+ it 'forwards start time to the socket' do
+ allow(socket).to receive(:readuntil).and_call_original
+ expect(socket).to receive(:readuntil).with("\n", true, kind_of(Numeric))
+
+ each_response_header
+ end
+
+ context 'when the response contains an infinite number of headers' do
+ before do
+ read_counter = 0
+
+ allow(mock_io).to receive(:read_nonblock) do
+ read_counter += 1
+ raise 'Test did not raise HeaderReadTimeout' if read_counter > 10
+
+ sleep 0.01
+ +"Yet-Another-Header: foo\n"
+ end
+ end
+
+ it 'raises a timeout error' do
+ expect { each_response_header }.to raise_error(Gitlab::HTTP_V2::HeaderReadTimeout,
+ /Request timed out after reading headers for 0\.[0-9]+ seconds/)
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/url_allowlist_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/url_allowlist_spec.rb
new file mode 100644
index 00000000000..bac69a2c38c
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/url_allowlist_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2::UrlAllowlist do
+ let(:allowlist) { [] }
+
+ describe '#domain_allowed?' do
+ let(:allowlist) { %w[www.example.com example.com] }
+
+ it 'returns true if domains present in allowlist' do
+ not_allowed = %w[subdomain.example.com example.org]
+
+ aggregate_failures do
+ allowlist.each do |domain|
+ expect(described_class).to be_domain_allowed(domain, allowlist)
+ end
+
+ not_allowed.each do |domain|
+ expect(described_class).not_to be_domain_allowed(domain, allowlist)
+ end
+ end
+ end
+
+ it 'returns false when domain is blank' do
+ expect(described_class).not_to be_domain_allowed(nil, allowlist)
+ end
+
+ context 'with ports' do
+ let(:allowlist) { ['example.io:3000'] }
+
+ it 'returns true if domain and ports present in allowlist' do
+ parsed_allowlist = [['example.io', 3000]]
+ not_allowed = [
+ 'example.io',
+ ['example.io', 3001]
+ ]
+
+ aggregate_failures do
+ parsed_allowlist.each do |domain, port|
+ expect(described_class).to be_domain_allowed(domain, allowlist, port: port)
+ end
+
+ not_allowed.each do |domain, port|
+ expect(described_class).not_to be_domain_allowed(domain, allowlist, port: port)
+ end
+ end
+ end
+ end
+ end
+
+ describe '#ip_allowed?' do
+ let(:allowlist) do
+ [
+ '0.0.0.0',
+ '127.0.0.1',
+ '192.168.1.1',
+ '0:0:0:0:0:ffff:192.168.1.2',
+ '::ffff:c0a8:102',
+ 'fc00:bf8b:e62c:abcd:abcd:aaaa:aaaa:aaaa',
+ '0:0:0:0:0:ffff:169.254.169.254',
+ '::ffff:a9fe:a9fe',
+ '::ffff:a9fe:a864',
+ 'fe80::c800:eff:fe74:8'
+ ]
+ end
+
+ it 'returns true if ips present in allowlist' do
+ aggregate_failures do
+ allowlist.each do |ip_address|
+ expect(described_class).to be_ip_allowed(ip_address, allowlist)
+ end
+
+ %w[172.16.2.2 127.0.0.2 fe80::c800:eff:fe74:9].each do |ip_address|
+ expect(described_class).not_to be_ip_allowed(ip_address, allowlist)
+ end
+ end
+ end
+
+ it 'returns false when ip is blank' do
+ expect(described_class).not_to be_ip_allowed(nil, allowlist)
+ end
+
+ context 'with ip ranges in allowlist' do
+ let(:ipv4_range) { '127.0.0.0/28' }
+ let(:ipv6_range) { 'fd84:6d02:f6d8:c89e::/124' }
+
+ let(:allowlist) do
+ [
+ ipv4_range,
+ ipv6_range
+ ]
+ end
+
+ it 'does not allowlist ipv4 range when not in allowlist' do
+ IPAddr.new(ipv4_range).to_range.to_a.each do |ip|
+ expect(described_class).not_to be_ip_allowed(ip.to_s, [])
+ end
+ end
+
+ it 'allowlists all ipv4s in the range when in allowlist' do
+ IPAddr.new(ipv4_range).to_range.to_a.each do |ip|
+ expect(described_class).to be_ip_allowed(ip.to_s, allowlist)
+ end
+ end
+
+ it 'does not allowlist ipv6 range when not in allowlist' do
+ IPAddr.new(ipv6_range).to_range.to_a.each do |ip|
+ expect(described_class).not_to be_ip_allowed(ip.to_s, [])
+ end
+ end
+
+ it 'allowlists all ipv6s in the range when in allowlist' do
+ IPAddr.new(ipv6_range).to_range.to_a.each do |ip|
+ expect(described_class).to be_ip_allowed(ip.to_s, allowlist)
+ end
+ end
+
+ it 'does not allowlist IPs outside the range' do
+ expect(described_class).not_to be_ip_allowed("fd84:6d02:f6d8:c89e:0:0:1:f", allowlist)
+
+ expect(described_class).not_to be_ip_allowed("127.0.1.15", allowlist)
+ end
+ end
+
+ context 'with ports' do
+ let(:allowlist) { %w[127.0.0.9:3000 [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443] }
+
+ it 'returns true if ip and ports present in allowlist' do
+ parsed_allowlist = [
+ ['127.0.0.9', 3000],
+ ['[2001:db8:85a3:8d3:1319:8a2e:370:7348]', 443]
+ ]
+ not_allowed = [
+ '127.0.0.9',
+ ['127.0.0.9', 3001],
+ '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
+ ['[2001:db8:85a3:8d3:1319:8a2e:370:7348]', 3001]
+ ]
+
+ aggregate_failures do
+ parsed_allowlist.each do |ip, port|
+ expect(described_class).to be_ip_allowed(ip, allowlist, port: port)
+ end
+
+ not_allowed.each do |ip, port|
+ expect(described_class).not_to be_ip_allowed(ip, allowlist, port: port)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2/url_blocker_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2/url_blocker_spec.rb
new file mode 100644
index 00000000000..dd7dda96a28
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2/url_blocker_spec.rb
@@ -0,0 +1,956 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2::UrlBlocker, :stub_invalid_dns_only, feature_category: :shared do
+ let(:schemes) { %w[http https] }
+
+ # This test ensures backward compatibliity for the validate! method.
+ # We shoud refactor all callers of validate! to handle a Result object:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/410890
+ describe '#validate!' do
+ let(:options) { { schemes: schemes } }
+
+ subject { described_class.validate!(import_url, **options) }
+
+ shared_examples 'validates URI and hostname' do
+ it 'runs the url validations' do
+ uri, hostname = subject
+
+ expect(uri).to eq(Addressable::URI.parse(expected_uri))
+ expect(hostname).to eq(expected_hostname)
+ end
+ end
+
+ context 'when the URL hostname is a domain' do
+ context 'when domain can be resolved' do
+ let(:import_url) { 'https://example.org' }
+
+ before do
+ stub_dns(import_url, ip_address: '93.184.216.34')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'https://93.184.216.34' }
+ let(:expected_hostname) { 'example.org' }
+ let(:expected_use_proxy) { false }
+ end
+ end
+ end
+ end
+
+ describe '#validate_url_with_proxy!' do
+ let(:options) { { schemes: schemes } }
+
+ subject { described_class.validate_url_with_proxy!(import_url, **options) }
+
+ shared_examples 'validates URI and hostname' do
+ it 'runs the url validations' do
+ expect(subject.uri).to eq(Addressable::URI.parse(expected_uri))
+ expect(subject.hostname).to eq(expected_hostname)
+ expect(subject.use_proxy).to eq(expected_use_proxy)
+ end
+ end
+
+ shared_context 'when instance configured to deny all requests' do
+ let(:options) { super().merge(deny_all_requests_except_allowed: true) }
+ end
+
+ shared_examples 'a URI denied by `deny_all_requests_except_allowed`' do
+ context 'when instance setting is enabled' do
+ include_context 'when instance configured to deny all requests'
+
+ it 'blocks the request' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'when instance setting is not enabled' do
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when passed as an argument' do
+ let(:options) { super().merge(deny_all_requests_except_allowed: arg_value) }
+
+ context 'when argument is a proc that evaluates to true' do
+ let(:arg_value) { proc { true } }
+
+ it 'blocks the request' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'when argument is a proc that evaluates to false' do
+ let(:arg_value) { proc { false } }
+
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when argument is true' do
+ let(:arg_value) { true }
+
+ it 'blocks the request' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'when argument is false' do
+ let(:arg_value) { false }
+
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ shared_examples 'a URI exempt from `deny_all_requests_except_allowed`' do
+ include_context 'when instance configured to deny all requests'
+
+ it 'does not block the request' do
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when URI is nil' do
+ let(:import_url) { nil }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { nil }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { true }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+ end
+
+ context 'when URI is internal' do
+ let(:import_url) { 'http://localhost' }
+
+ before do
+ stub_dns(import_url, ip_address: '127.0.0.1')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'http://127.0.0.1' }
+ let(:expected_hostname) { 'localhost' }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+ end
+
+ context 'when URI is for a local object storage' do
+ let(:import_url) { "#{host}/external-diffs/merge_request_diffs/mr-1/diff-1" }
+
+ context 'when extra_allowed_uris is passed' do
+ let(:options) { super().merge(extra_allowed_uris: [URI(host)]) }
+
+ context 'with a local domain name' do
+ let(:host) { 'http://review-minio-svc.svc:9000' }
+
+ before do
+ stub_dns(host, ip_address: '127.0.0.1')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'http://127.0.0.1:9000/external-diffs/merge_request_diffs/mr-1/diff-1' }
+ let(:expected_hostname) { 'review-minio-svc.svc' }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+ end
+
+ context 'with an IP address' do
+ let(:host) { 'http://127.0.0.1:9000' }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'http://127.0.0.1:9000/external-diffs/merge_request_diffs/mr-1/diff-1' }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+ end
+
+ context 'with an LFS object storage' do
+ let(:host) { 'http://127.0.0.1:9000' }
+
+ context 'when extra_allowed_uris is not passed' do
+ let(:options) { super().merge(extra_allowed_uris: []) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+ end
+ end
+
+ context 'when extra_allowed_uris is not passed' do
+ context 'with a local domain name' do
+ let(:host) { 'http://review-minio-svc.svc:9000' }
+
+ before do
+ stub_dns(host, ip_address: '127.0.0.1')
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+
+ context 'with an IP address' do
+ let(:host) { 'http://127.0.0.1:9000' }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+ end
+ end
+
+ context 'when the URL hostname is a domain' do
+ context 'when domain can be resolved' do
+ let(:import_url) { 'https://example.org' }
+
+ before do
+ stub_dns(import_url, ip_address: '93.184.216.34')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'https://93.184.216.34' }
+ let(:expected_hostname) { 'example.org' }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+ end
+
+ context 'when domain cannot be resolved' do
+ let(:import_url) { 'http://foobar.x' }
+
+ before do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+
+ context 'with HTTP_PROXY' do
+ let(:import_url) { 'http://foobar.x' }
+
+ before do
+ stub_env('http_proxy', 'http://proxy.example.com')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { true }
+ end
+
+ context 'with no_proxy' do
+ before do
+ stub_env('no_proxy', 'foobar.x')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+ end
+ end
+ end
+
+ context 'when domain is too long' do
+ let(:import_url) { "https://example#{'a' * 1024}.com" }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+ end
+
+ context 'when the URL hostname is an IP address' do
+ let(:import_url) { 'https://93.184.216.34' }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+
+ context 'when the address is invalid' do
+ let(:import_url) { 'http://1.1.1.1.1' }
+
+ it 'raises an error' do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+
+ expect { subject }.to raise_error(described_class::BlockedUrlError)
+ end
+ end
+ end
+
+ context 'when DNS rebinding protection with IP allowed' do
+ let(:import_url) { 'http://a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network:9121/scrape?target=unix:///var/opt/gitlab/redis/redis.socket&amp;check-keys=*' }
+
+ before do
+ stub_dns(import_url, ip_address: '192.168.0.120')
+
+ allow(Gitlab::HTTP_V2::UrlAllowlist).to receive(:ip_allowed?).and_return(true)
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'http://192.168.0.120:9121/scrape?target=unix:///var/opt/gitlab/redis/redis.socket&amp;check-keys=*' }
+ let(:expected_hostname) { 'a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network' }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+
+ context 'with HTTP_PROXY' do
+ before do
+ stub_env('http_proxy', 'http://proxy.example.com')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { true }
+ end
+
+ context 'when domain is in no_proxy env' do
+ before do
+ stub_env('no_proxy', 'a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network')
+ end
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { 'http://192.168.0.120:9121/scrape?target=unix:///var/opt/gitlab/redis/redis.socket&amp;check-keys=*' }
+ let(:expected_hostname) { 'a.192.168.0.120.3times.127.0.0.1.1time.repeat.rebind.network' }
+ let(:expected_use_proxy) { false }
+ end
+ end
+ end
+ end
+
+ context 'with disabled DNS rebinding protection' do
+ let(:options) { { dns_rebind_protection: false, schemes: schemes } }
+
+ context 'when URI is internal' do
+ let(:import_url) { 'http://localhost' }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI exempt from `deny_all_requests_except_allowed`'
+ end
+
+ context 'when the URL hostname is a domain' do
+ let(:import_url) { 'https://example.org' }
+
+ before do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+ end
+
+ context 'when domain can be resolved' do
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+ end
+
+ context 'when domain cannot be resolved' do
+ let(:import_url) { 'http://foobar.x' }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+ end
+ end
+
+ context 'when the URL hostname is an IP address' do
+ let(:import_url) { 'https://93.184.216.34' }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+
+ context 'when it is invalid' do
+ let(:import_url) { 'http://1.1.1.1.1' }
+
+ it_behaves_like 'validates URI and hostname' do
+ let(:expected_uri) { import_url }
+ let(:expected_hostname) { nil }
+ let(:expected_use_proxy) { false }
+ end
+
+ it_behaves_like 'a URI denied by `deny_all_requests_except_allowed`'
+ end
+ end
+ end
+ end
+
+ describe '#blocked_url?' do
+ let(:ports) { [80, 443] }
+
+ it 'allows imports from configured web host and port' do
+ import_url = "http://localhost:80/t.git"
+ expect(described_class.blocked_url?(import_url, schemes: schemes)).to be false
+ end
+
+ it 'allows mirroring from configured SSH host and port' do
+ import_url = "ssh://localhost:22/t.git"
+ expect(described_class.blocked_url?(import_url, schemes: schemes)).to be false
+ end
+
+ it 'returns true for bad localhost hostname' do
+ expect(described_class.blocked_url?('https://localhost:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for bad port' do
+ expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git', ports: ports, schemes: schemes)).to be true
+ end
+
+ it 'returns true for bad scheme' do
+ expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git', schemes: ['https'])).to be false
+ expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git', schemes: ['http'])).to be true
+ end
+
+ it 'returns true for bad protocol on configured web/SSH host and ports' do
+ web_url = "javascript://localhost:80/t.git%0aalert(1)"
+ expect(described_class.blocked_url?(web_url, schemes: schemes)).to be true
+
+ ssh_url = "javascript://localhost:22/t.git%0aalert(1)"
+ expect(described_class.blocked_url?(ssh_url, schemes: schemes)).to be true
+ end
+
+ it 'returns true for localhost IPs' do
+ expect(described_class.blocked_url?('https://[0:0:0:0:0:0:0:0]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://0.0.0.0/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::]/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for loopback IP' do
+ expect(described_class.blocked_url?('https://127.0.0.2/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://127.0.0.1/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::1]/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (0177.1)' do
+ expect(described_class.blocked_url?('https://0177.1:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (017700000001)' do
+ expect(described_class.blocked_url?('https://017700000001:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (0x7f.1)' do
+ expect(described_class.blocked_url?('https://0x7f.1:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (0x7f.0.0.1)' do
+ expect(described_class.blocked_url?('https://0x7f.0.0.1:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (0x7f000001)' do
+ expect(described_class.blocked_url?('https://0x7f000001:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (2130706433)' do
+ expect(described_class.blocked_url?('https://2130706433:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (127.000.000.001)' do
+ expect(described_class.blocked_url?('https://127.000.000.001:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for alternative version of 127.0.0.1 (127.0.1)' do
+ expect(described_class.blocked_url?('https://127.0.1:65535/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ context 'with ipv6 mapped address' do
+ it 'returns true for localhost IPs' do
+ expect(described_class.blocked_url?('https://[0:0:0:0:0:ffff:0.0.0.0]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::ffff:0.0.0.0]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::ffff:0:0]/foo/foo.git', schemes: schemes)).to be true
+ end
+
+ it 'returns true for loopback IPs' do
+ expect(described_class.blocked_url?('https://[0:0:0:0:0:ffff:127.0.0.1]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::ffff:127.0.0.1]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::ffff:7f00:1]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[0:0:0:0:0:ffff:127.0.0.2]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::ffff:127.0.0.2]/foo/foo.git', schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://[::ffff:7f00:2]/foo/foo.git', schemes: schemes)).to be true
+ end
+ end
+
+ it 'returns true for a non-alphanumeric hostname' do
+ aggregate_failures do
+ expect(described_class).to be_blocked_url('ssh://-oProxyCommand=whoami/a', schemes: ['ssh'])
+
+ # The leading character here is a Unicode "soft hyphen"
+ expect(described_class).to be_blocked_url('ssh://­oProxyCommand=whoami/a', schemes: ['ssh'])
+
+ # Unicode alphanumerics are allowed
+ expect(described_class).not_to be_blocked_url('ssh://ğitlab.com/a', schemes: ['ssh'])
+ end
+ end
+
+ it 'returns true for invalid URL' do
+ expect(described_class.blocked_url?('http://:8080', schemes: schemes)).to be true
+ end
+
+ it 'returns false for legitimate URL' do
+ expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git', schemes: schemes)).to be false
+ end
+
+ describe 'allow_local_network' do
+ let(:shared_address_space_ips) { ['100.64.0.0', '100.64.127.127', '100.64.255.255'] }
+
+ let(:local_ips) do
+ [
+ '192.168.1.2',
+ '[0:0:0:0:0:ffff:192.168.1.2]',
+ '[::ffff:c0a8:102]',
+ '10.0.0.2',
+ '[0:0:0:0:0:ffff:10.0.0.2]',
+ '[::ffff:a00:2]',
+ '172.16.0.2',
+ '[0:0:0:0:0:ffff:172.16.0.2]',
+ '[::ffff:ac10:20]',
+ '[feef::1]',
+ '[fee2::]',
+ '[fc00:bf8b:e62c:abcd:abcd:aaaa:aaaa:aaaa]',
+ *shared_address_space_ips
+ ]
+ end
+
+ let(:limited_broadcast_address_variants) do
+ [
+ '255.255.255.255', # "normal" dotted decimal
+ '0377.0377.0377.0377', # Octal
+ '0377.00000000377.00377.0000377', # Still octal
+ '0xff.0xff.0xff.0xff', # hex
+ '0xffffffff', # still hex
+ '0xBaaaaaaaaaaaaaaaaffffffff', # padded hex
+ '255.255.255.255:65535', # with a port
+ '4294967295', # as an integer / dword
+ '[::ffff:ffff:ffff]', # short IPv6
+ '[0000:0000:0000:0000:0000:ffff:ffff:ffff]' # long IPv6
+ ]
+ end
+
+ let(:fake_domain) { 'www.fakedomain.fake' }
+
+ shared_examples 'allows local requests' do
+ it 'does not block urls from private networks' do
+ local_ips.each do |ip|
+ stub_domain_resolv(fake_domain, ip) do
+ expect(described_class).not_to be_blocked_url("http://#{fake_domain}", **url_blocker_attributes)
+ end
+
+ expect(described_class).not_to be_blocked_url("http://#{ip}", **url_blocker_attributes)
+ end
+ end
+
+ it 'allows localhost endpoints' do
+ expect(described_class).not_to be_blocked_url('http://0.0.0.0', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://localhost', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://127.0.0.1', **url_blocker_attributes)
+ end
+
+ it 'allows loopback endpoints' do
+ expect(described_class).not_to be_blocked_url('http://127.0.0.2', **url_blocker_attributes)
+ end
+
+ it 'allows IPv4 link-local endpoints' do
+ expect(described_class).not_to be_blocked_url('http://169.254.169.254', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://169.254.168.100', **url_blocker_attributes)
+ end
+
+ it 'allows IPv6 link-local endpoints' do
+ expect(described_class).not_to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.169.254]', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://[::ffff:169.254.169.254]', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://[::ffff:a9fe:a9fe]', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.168.100]', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://[::ffff:169.254.168.100]', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://[::ffff:a9fe:a864]', **url_blocker_attributes)
+ expect(described_class).not_to be_blocked_url('http://[fe80::c800:eff:fe74:8]', **url_blocker_attributes)
+ end
+
+ it 'allows limited broadcast address 255.255.255.255 and variants' do
+ limited_broadcast_address_variants.each do |variant|
+ expect(described_class).not_to be_blocked_url("https://#{variant}", **url_blocker_attributes), "Expected #{variant} to be allowed"
+ end
+ end
+ end
+
+ context 'when true (default)' do
+ let(:url_blocker_attributes) do
+ options.merge(
+ allow_localhost: true,
+ allow_local_network: true
+ )
+ end
+
+ let(:options) { { schemes: schemes } }
+
+ it_behaves_like 'allows local requests', { allow_localhost: true, allow_local_network: true, schemes: %w[http https] }
+ end
+
+ context 'when false' do
+ it 'blocks urls from private networks' do
+ local_ips.each do |ip|
+ stub_domain_resolv(fake_domain, ip) do
+ expect(described_class).to be_blocked_url("http://#{fake_domain}", allow_local_network: false, schemes: schemes)
+ end
+
+ expect(described_class).to be_blocked_url("http://#{ip}", allow_local_network: false, schemes: schemes)
+ end
+ end
+
+ it 'blocks IPv4 link-local endpoints' do
+ expect(described_class).to be_blocked_url('http://169.254.169.254', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://169.254.168.100', allow_local_network: false, schemes: schemes)
+ end
+
+ it 'blocks IPv6 link-local endpoints' do
+ expect(described_class).to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.169.254]', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://[::ffff:169.254.169.254]', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://[::ffff:a9fe:a9fe]', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://[0:0:0:0:0:ffff:169.254.168.100]', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://[::ffff:169.254.168.100]', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://[::ffff:a9fe:a864]', allow_local_network: false, schemes: schemes)
+ expect(described_class).to be_blocked_url('http://[fe80::c800:eff:fe74:8]', allow_local_network: false, schemes: schemes)
+ end
+
+ it 'blocks limited broadcast address 255.255.255.255 and variants' do
+ # Raise BlockedUrlError for invalid URLs.
+ # The padded hex version, for example, is a valid URL on Mac but
+ # not on Ubuntu.
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+
+ limited_broadcast_address_variants.each do |variant|
+ expect(described_class).to be_blocked_url("https://#{variant}", allow_local_network: false, schemes: schemes), "Expected #{variant} to be blocked"
+ end
+ end
+
+ context 'when local domain/IP is allowed' do
+ let(:url_blocker_attributes) do
+ options.merge(
+ allow_localhost: false,
+ allow_local_network: false
+ )
+ end
+
+ let(:options) { { schemes: schemes, outbound_local_requests_allowlist: allowlist } }
+
+ context 'with IPs in allowlist' do
+ let(:allowlist) do
+ [
+ '0.0.0.0',
+ '127.0.0.1',
+ '127.0.0.2',
+ '192.168.1.1',
+ *local_ips,
+ '0:0:0:0:0:ffff:169.254.169.254',
+ '::ffff:a9fe:a9fe',
+ '::ffff:169.254.168.100',
+ '::ffff:a9fe:a864',
+ 'fe80::c800:eff:fe74:8',
+ '255.255.255.255',
+
+ # garbage IPs
+ '45645632345',
+ 'garbage456:more345gar:bage'
+ ]
+ end
+
+ it_behaves_like 'allows local requests', { allow_localhost: false, allow_local_network: false, schemes: %w[http https] }
+
+ it 'allows IP when dns_rebind_protection is disabled' do
+ url = "http://example.com"
+ attrs = url_blocker_attributes.merge(dns_rebind_protection: false)
+
+ stub_domain_resolv('example.com', '192.168.1.2') do
+ expect(described_class).not_to be_blocked_url(url, **attrs)
+ end
+
+ stub_domain_resolv('example.com', '192.168.1.3') do
+ expect(described_class).to be_blocked_url(url, **attrs)
+ end
+ end
+
+ it 'allows the limited broadcast address 255.255.255.255' do
+ expect(described_class).not_to be_blocked_url('http://255.255.255.255', **url_blocker_attributes)
+ end
+ end
+
+ context 'with domains in allowlist' do
+ let(:allowlist) do
+ [
+ 'www.example.com',
+ 'example.com',
+ 'xn--itlab-j1a.com',
+ 'garbage$^$%#$^&$'
+ ]
+ end
+
+ it 'allows domains present in allowlist' do
+ domain = 'example.com'
+ subdomain1 = 'www.example.com'
+ subdomain2 = 'subdomain.example.com'
+
+ stub_domain_resolv(domain, '192.168.1.1') do
+ expect(described_class).not_to be_blocked_url("http://#{domain}",
+ **url_blocker_attributes)
+ end
+
+ stub_domain_resolv(subdomain1, '192.168.1.1') do
+ expect(described_class).not_to be_blocked_url("http://#{subdomain1}",
+ **url_blocker_attributes)
+ end
+
+ # subdomain2 is not part of the allowlist so it should be blocked
+ stub_domain_resolv(subdomain2, '192.168.1.1') do
+ expect(described_class).to be_blocked_url("http://#{subdomain2}",
+ **url_blocker_attributes)
+ end
+ end
+
+ it 'works with unicode and idna encoded domains' do
+ unicode_domain = 'ğitlab.com'
+ idna_encoded_domain = 'xn--itlab-j1a.com'
+
+ stub_domain_resolv(unicode_domain, '192.168.1.1') do
+ expect(described_class).not_to be_blocked_url("http://#{unicode_domain}",
+ **url_blocker_attributes)
+ end
+
+ stub_domain_resolv(idna_encoded_domain, '192.168.1.1') do
+ expect(described_class).not_to be_blocked_url("http://#{idna_encoded_domain}",
+ **url_blocker_attributes)
+ end
+ end
+
+ shared_examples 'dns rebinding checks' do
+ shared_examples 'allowlists the domain' do
+ let(:allowlist) { [domain] }
+ let(:url) { "http://#{domain}" }
+
+ before do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+ end
+
+ it do
+ expect(described_class).not_to be_blocked_url(url, **options, dns_rebind_protection: dns_rebind_value)
+ end
+ end
+
+ describe 'dns_rebinding_setting' do
+ context 'when enabled' do
+ let(:dns_rebind_value) { true }
+
+ it_behaves_like 'allowlists the domain'
+ end
+
+ context 'when disabled' do
+ let(:dns_rebind_value) { false }
+
+ it_behaves_like 'allowlists the domain'
+ end
+ end
+ end
+
+ context 'when the domain cannot be resolved' do
+ let(:domain) { 'foobar.x' }
+
+ it_behaves_like 'dns rebinding checks'
+ end
+
+ context 'when the domain can be resolved' do
+ let(:domain) { 'example.com' }
+
+ before do
+ stub_dns(url, ip_address: '93.184.216.34')
+ end
+
+ it_behaves_like 'dns rebinding checks'
+ end
+ end
+
+ context 'with ports' do
+ let(:allowlist) do
+ ["127.0.0.1:2000"]
+ end
+
+ it 'allows domain with port when resolved ip has port allowed' do
+ stub_domain_resolv("www.resolve-domain.com", '127.0.0.1', 2000) do
+ expect(described_class).not_to be_blocked_url("http://www.resolve-domain.com:2000", **url_blocker_attributes)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ describe 'enforce_user' do
+ context 'when false (default)' do
+ it 'does not block urls with a non-alphanumeric username' do
+ expect(described_class).not_to be_blocked_url('ssh://-oProxyCommand=whoami@example.com/a', schemes: ['ssh'])
+
+ # The leading character here is a Unicode "soft hyphen"
+ expect(described_class).not_to be_blocked_url('ssh://­oProxyCommand=whoami@example.com/a', schemes: ['ssh'])
+
+ # Unicode alphanumerics are allowed
+ expect(described_class).not_to be_blocked_url('ssh://ğitlab@example.com/a', schemes: ['ssh'])
+ end
+ end
+
+ context 'when true' do
+ it 'blocks urls with a non-alphanumeric username' do
+ aggregate_failures do
+ expect(described_class).to be_blocked_url('ssh://-oProxyCommand=whoami@example.com/a', enforce_user: true, schemes: ['ssh'])
+
+ # The leading character here is a Unicode "soft hyphen"
+ expect(described_class).to be_blocked_url('ssh://­oProxyCommand=whoami@example.com/a', enforce_user: true, schemes: ['ssh'])
+
+ # Unicode alphanumerics are allowed
+ expect(described_class).not_to be_blocked_url('ssh://ğitlab@example.com/a', enforce_user: true, schemes: ['ssh'])
+ end
+ end
+ end
+ end
+
+ context 'when ascii_only is true' do
+ it 'returns true for unicode domain' do
+ expect(described_class.blocked_url?('https://𝕘itⅼαƄ.com/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true
+ end
+
+ it 'returns true for unicode tld' do
+ expect(described_class.blocked_url?('https://gitlab.ᴄοm/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true
+ end
+
+ it 'returns true for unicode path' do
+ expect(described_class.blocked_url?('https://gitlab.com/𝒇οο/𝒇οο.Ƅαꮁ', ascii_only: true, schemes: schemes)).to be true
+ end
+
+ it 'returns true for IDNA deviations' do
+ expect(described_class.blocked_url?('https://mißile.com/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://miςςile.com/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://git‍lab.com/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true
+ expect(described_class.blocked_url?('https://git‌lab.com/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true
+ end
+ end
+
+ it 'blocks urls with invalid ip address' do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+
+ expect(described_class).to be_blocked_url('http://8.8.8.8.8', schemes: schemes)
+ end
+
+ it 'blocks urls whose hostname cannot be resolved' do
+ stub_env('RSPEC_ALLOW_INVALID_URLS', 'false')
+
+ expect(described_class).to be_blocked_url('http://foobar.x', schemes: schemes)
+ end
+
+ context 'when gitlab is running on a non-default port' do
+ let(:gitlab_port) { 3000 }
+
+ before do
+ Gitlab::HTTP_V2.configuration.allowed_internal_uris = [
+ URI::HTTP.build(
+ scheme: 'http',
+ host: 'gitlab.local',
+ port: gitlab_port
+ )
+ ]
+ end
+
+ it 'returns true for url targeting the wrong port' do
+ stub_domain_resolv('gitlab.local', '127.0.0.1') do
+ expect(described_class).to be_blocked_url("http://gitlab.local/foo", schemes: schemes)
+ end
+ end
+
+ it 'does not block url on gitlab port' do
+ stub_domain_resolv('gitlab.local', '127.0.0.1', gitlab_port) do
+ expect(described_class).not_to be_blocked_url("http://gitlab.local:#{gitlab_port}/foo", schemes: schemes)
+ end
+ end
+ end
+
+ def stub_domain_resolv(domain, ip, port = 80)
+ address = instance_double(Addrinfo,
+ ip_address: ip,
+ ipv4_private?: true,
+ ipv6_linklocal?: false,
+ ipv4_loopback?: false,
+ ipv6_loopback?: false,
+ ipv4?: false,
+ ip_port: port
+ )
+ allow(Addrinfo).to receive(:getaddrinfo).with(domain, port, any_args).and_return([address])
+ allow(address).to receive(:ipv6_v4mapped?).and_return(false)
+
+ yield
+
+ allow(Addrinfo).to receive(:getaddrinfo).and_call_original
+ end
+ end
+
+ describe '#validate_hostname' do
+ let(:ip_addresses) do
+ [
+ '2001:db8:1f70::999:de8:7648:6e8',
+ 'FE80::C800:EFF:FE74:8',
+ '::ffff:127.0.0.1',
+ '::ffff:169.254.168.100',
+ '::ffff:7f00:1',
+ '0:0:0:0:0:ffff:0.0.0.0',
+ 'localhost',
+ '127.0.0.1',
+ '127.000.000.001',
+ '0x7f000001',
+ '0x7f.0.0.1',
+ '0x7f.0.0.1',
+ '017700000001',
+ '0177.1',
+ '2130706433',
+ '::',
+ '::1'
+ ]
+ end
+
+ it 'does not raise error for valid Ip addresses' do
+ ip_addresses.each do |ip|
+ expect { described_class.send(:validate_hostname, ip) }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/http_v2_spec.rb b/gems/gitlab-http/spec/gitlab/http_v2_spec.rb
new file mode 100644
index 00000000000..0c05c7b2b4f
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/http_v2_spec.rb
@@ -0,0 +1,441 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HTTP_V2, feature_category: :shared do
+ context 'when allow_local_requests' do
+ it 'sends the request to the correct URI' do
+ stub_full_request('https://example.org:8080', ip_address: '8.8.8.8').to_return(status: 200)
+
+ described_class.get('https://example.org:8080', allow_local_requests: false)
+
+ expect(WebMock).to have_requested(:get, 'https://8.8.8.8:8080').once
+ end
+ end
+
+ context 'when not allow_local_requests' do
+ it 'sends the request to the correct URI' do
+ stub_full_request('https://example.org:8080')
+
+ described_class.get('https://example.org:8080', allow_local_requests: true)
+
+ expect(WebMock).to have_requested(:get, 'https://8.8.8.9:8080').once
+ end
+ end
+
+ context 'when reading the response is too slow' do
+ before(:all) do
+ # Override Net::HTTP to add a delay between sending each response chunk
+ mocked_http = Class.new(Net::HTTP) do
+ def request(*)
+ super do |response|
+ response.instance_eval do
+ def read_body(*)
+ mock_stream = @body.split(' ')
+ mock_stream.each do |fragment|
+ sleep 0.002.seconds
+
+ yield fragment if block_given?
+ end
+
+ @body
+ end
+ end
+
+ yield response if block_given?
+
+ response
+ end
+ end
+ end
+
+ @original_net_http = Net.send(:remove_const, :HTTP)
+ @webmock_net_http = WebMock::HttpLibAdapters::NetHttpAdapter.instance_variable_get(:@webMockNetHTTP)
+
+ Net.send(:const_set, :HTTP, mocked_http)
+ WebMock::HttpLibAdapters::NetHttpAdapter.instance_variable_set(:@webMockNetHTTP, mocked_http)
+
+ # Reload Gitlab::NetHttpAdapter
+ described_class.send(:remove_const, :NetHttpAdapter)
+ load "gitlab/http_v2/net_http_adapter.rb"
+ end
+
+ before do
+ stub_const("#{described_class}::Client::DEFAULT_READ_TOTAL_TIMEOUT", 0.001.seconds)
+
+ WebMock.stub_request(:post, /.*/).to_return do
+ { body: "chunk-1 chunk-2", status: 200 }
+ end
+ end
+
+ after(:all) do
+ Net.send(:remove_const, :HTTP)
+ Net.send(:const_set, :HTTP, @original_net_http)
+ WebMock::HttpLibAdapters::NetHttpAdapter.instance_variable_set(:@webMockNetHTTP, @webmock_net_http)
+
+ # Reload Gitlab::NetHttpAdapter
+ described_class.send(:remove_const, :NetHttpAdapter)
+ load "gitlab/http_v2/net_http_adapter.rb"
+ end
+
+ let(:options) { {} }
+
+ subject(:request_slow_responder) { described_class.post('http://example.org', **options) }
+
+ it 'raises an error' do
+ expect do
+ request_slow_responder
+ end.to raise_error(Gitlab::HTTP_V2::ReadTotalTimeout,
+ /Request timed out after ?([0-9]*[.])?[0-9]+ seconds/)
+ end
+
+ context 'and timeout option is greater than DEFAULT_READ_TOTAL_TIMEOUT' do
+ let(:options) { { timeout: 10.seconds } }
+
+ it 'does not raise an error' do
+ expect { request_slow_responder }.not_to raise_error
+ end
+ end
+
+ context 'and stream_body option is truthy' do
+ let(:options) { { stream_body: true } }
+
+ it 'does not raise an error' do
+ expect { request_slow_responder }.not_to raise_error
+ end
+ end
+ end
+
+ it 'calls a block' do
+ WebMock.stub_request(:post, /.*/)
+
+ expect { |b| described_class.post('http://example.org', &b) }.to yield_with_args
+ end
+
+ describe 'allow_local_requests is' do
+ before do
+ WebMock.stub_request(:get, /.*/).to_return(status: 200, body: 'Success')
+ end
+
+ context 'disabled' do
+ it 'deny requests to localhost' do
+ expect { described_class.get('http://localhost:3003', allow_local_requests: false) }.to raise_error(Gitlab::HTTP_V2::BlockedUrlError)
+ end
+
+ it 'deny requests to private network' do
+ expect { described_class.get('http://192.168.1.2:3003', allow_local_requests: false) }.to raise_error(Gitlab::HTTP_V2::BlockedUrlError)
+ end
+
+ context 'if allow_local_requests set to true' do
+ it 'override the global value and allow requests to localhost or private network' do
+ stub_full_request('http://localhost:3003')
+
+ expect { described_class.get('http://localhost:3003', allow_local_requests: true) }.not_to raise_error
+ end
+ end
+ end
+
+ context 'enabled' do
+ it 'allow requests to localhost' do
+ stub_full_request('http://localhost:3003')
+
+ expect { described_class.get('http://localhost:3003', allow_local_requests: true) }.not_to raise_error
+ end
+
+ it 'allow requests to private network' do
+ expect { described_class.get('http://192.168.1.2:3003', allow_local_requests: true) }.not_to raise_error
+ end
+
+ context 'if allow_local_requests set to false' do
+ it 'override the global value and ban requests to localhost or private network' do
+ expect do
+ described_class.get('http://localhost:3003',
+ allow_local_requests: false)
+ end.to raise_error(Gitlab::HTTP_V2::BlockedUrlError)
+ end
+ end
+ end
+ end
+
+ describe 'handle redirect loops' do
+ before do
+ stub_full_request("http://example.org", method: :any)
+ .to_raise(HTTParty::RedirectionTooDeep.new("Redirection Too Deep"))
+ end
+
+ it 'handles GET requests' do
+ expect { described_class.get('http://example.org') }.to raise_error(Gitlab::HTTP_V2::RedirectionTooDeep)
+ end
+
+ it 'handles POST requests' do
+ expect { described_class.post('http://example.org') }.to raise_error(Gitlab::HTTP_V2::RedirectionTooDeep)
+ end
+
+ it 'handles PUT requests' do
+ expect { described_class.put('http://example.org') }.to raise_error(Gitlab::HTTP_V2::RedirectionTooDeep)
+ end
+
+ it 'handles DELETE requests' do
+ expect { described_class.delete('http://example.org') }.to raise_error(Gitlab::HTTP_V2::RedirectionTooDeep)
+ end
+
+ it 'handles HEAD requests' do
+ expect { described_class.head('http://example.org') }.to raise_error(Gitlab::HTTP_V2::RedirectionTooDeep)
+ end
+ end
+
+ describe 'setting default timeouts' do
+ let(:default_timeout_options) { described_class::Client::DEFAULT_TIMEOUT_OPTIONS }
+
+ before do
+ stub_full_request('http://example.org', method: :any)
+ end
+
+ context 'when no timeouts are set' do
+ it 'sets default open and read and write timeouts' do
+ expect(described_class::Client).to receive(:httparty_perform_request).with(
+ Net::HTTP::Get, 'http://example.org', default_timeout_options
+ ).and_call_original
+
+ described_class.get('http://example.org')
+ end
+ end
+
+ context 'when :timeout is set' do
+ it 'does not set any default timeouts' do
+ expect(described_class::Client).to receive(:httparty_perform_request).with(
+ Net::HTTP::Get, 'http://example.org', { timeout: 1 }
+ ).and_call_original
+
+ described_class.get('http://example.org', timeout: 1)
+ end
+ end
+
+ context 'when :open_timeout is set' do
+ it 'only sets default read and write timeout' do
+ expect(described_class::Client).to receive(:httparty_perform_request).with(
+ Net::HTTP::Get, 'http://example.org', default_timeout_options.merge(open_timeout: 1)
+ ).and_call_original
+
+ described_class.get('http://example.org', open_timeout: 1)
+ end
+ end
+
+ context 'when :read_timeout is set' do
+ it 'only sets default open and write timeout' do
+ expect(described_class::Client).to receive(:httparty_perform_request).with(
+ Net::HTTP::Get, 'http://example.org', default_timeout_options.merge(read_timeout: 1)
+ ).and_call_original
+
+ described_class.get('http://example.org', read_timeout: 1)
+ end
+ end
+
+ context 'when :write_timeout is set' do
+ it 'only sets default open and read timeout' do
+ expect(described_class::Client).to receive(:httparty_perform_request).with(
+ Net::HTTP::Put, 'http://example.org', default_timeout_options.merge(write_timeout: 1)
+ ).and_call_original
+
+ described_class.put('http://example.org', write_timeout: 1)
+ end
+ end
+ end
+
+ describe '.try_get' do
+ let(:path) { 'http://example.org' }
+ let(:default_timeout_options) { described_class::Client::DEFAULT_TIMEOUT_OPTIONS }
+
+ let(:extra_log_info_proc) do
+ proc do |error, url, options|
+ { klass: error.class, url: url, options: options }
+ end
+ end
+
+ let(:request_options) do
+ {
+ **default_timeout_options,
+ verify: false,
+ basic_auth: { username: 'user', password: 'pass' }
+ }
+ end
+
+ described_class::HTTP_ERRORS.each do |exception_class|
+ context "with #{exception_class}" do
+ let(:klass) { exception_class }
+
+ context 'with path' do
+ before do
+ expect(described_class::Client).to receive(:httparty_perform_request)
+ .with(Net::HTTP::Get, path, default_timeout_options)
+ .and_raise(klass)
+ end
+
+ it 'handles requests without extra_log_info' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), {})
+
+ expect(described_class.try_get(path)).to be_nil
+ end
+
+ it 'handles requests with extra_log_info as hash' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), { a: :b })
+
+ expect(described_class.try_get(path, extra_log_info: { a: :b })).to be_nil
+ end
+
+ it 'handles requests with extra_log_info as proc' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), { url: path, klass: klass, options: {} })
+
+ expect(described_class.try_get(path, extra_log_info: extra_log_info_proc)).to be_nil
+ end
+ end
+
+ context 'with path and options' do
+ before do
+ expect(described_class::Client).to receive(:httparty_perform_request)
+ .with(Net::HTTP::Get, path, request_options)
+ .and_raise(klass)
+ end
+
+ it 'handles requests without extra_log_info' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), {})
+
+ expect(described_class.try_get(path, request_options)).to be_nil
+ end
+
+ it 'handles requests with extra_log_info as hash' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), { a: :b })
+
+ expect(described_class.try_get(path, **request_options, extra_log_info: { a: :b })).to be_nil
+ end
+
+ it 'handles requests with extra_log_info as proc' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), { klass: klass, url: path, options: request_options })
+
+ expect(described_class.try_get(path, **request_options, extra_log_info: extra_log_info_proc)).to be_nil
+ end
+ end
+
+ context 'with path, options, and block' do
+ let(:block) do
+ proc {}
+ end
+
+ before do
+ expect(described_class::Client).to receive(:httparty_perform_request)
+ .with(Net::HTTP::Get, path, request_options, &block)
+ .and_raise(klass)
+ end
+
+ it 'handles requests without extra_log_info' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), {})
+
+ expect(described_class.try_get(path, request_options, &block)).to be_nil
+ end
+
+ it 'handles requests with extra_log_info as hash' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), { a: :b })
+
+ expect(described_class.try_get(path, **request_options, extra_log_info: { a: :b }, &block)).to be_nil
+ end
+
+ it 'handles requests with extra_log_info as proc' do
+ expect(described_class.configuration)
+ .to receive(:log_exception)
+ .with(instance_of(klass), { klass: klass, url: path, options: request_options })
+
+ expect(
+ described_class.try_get(path, **request_options, extra_log_info: extra_log_info_proc, &block)
+ ).to be_nil
+ end
+ end
+ end
+ end
+ end
+
+ describe 'silent mode', feature_category: :geo_replication do
+ before do
+ stub_full_request("http://example.org", method: :any)
+ end
+
+ context 'when silent mode is enabled' do
+ let(:silent_mode) { true }
+
+ it 'allows GET requests' do
+ expect { described_class.get('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'allows HEAD requests' do
+ expect { described_class.head('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'allows OPTIONS requests' do
+ expect { described_class.options('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'blocks POST requests' do
+ expect { described_class.post('http://example.org', silent_mode_enabled: silent_mode) }.to raise_error(Gitlab::HTTP_V2::SilentModeBlockedError)
+ end
+
+ it 'blocks PUT requests' do
+ expect { described_class.put('http://example.org', silent_mode_enabled: silent_mode) }.to raise_error(Gitlab::HTTP_V2::SilentModeBlockedError)
+ end
+
+ it 'blocks DELETE requests' do
+ expect { described_class.delete('http://example.org', silent_mode_enabled: silent_mode) }.to raise_error(Gitlab::HTTP_V2::SilentModeBlockedError)
+ end
+
+ it 'logs blocked requests' do
+ expect(described_class.configuration).to receive(:silent_mode_log_info).with(
+ "Outbound HTTP request blocked", 'Net::HTTP::Post'
+ )
+
+ expect { described_class.post('http://example.org', silent_mode_enabled: silent_mode) }.to raise_error(Gitlab::HTTP_V2::SilentModeBlockedError)
+ end
+ end
+
+ context 'when silent mode is disabled' do
+ let(:silent_mode) { false }
+
+ it 'allows GET requests' do
+ expect { described_class.get('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'allows HEAD requests' do
+ expect { described_class.head('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'allows OPTIONS requests' do
+ expect { described_class.options('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'blocks POST requests' do
+ expect { described_class.post('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'blocks PUT requests' do
+ expect { described_class.put('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+
+ it 'blocks DELETE requests' do
+ expect { described_class.delete('http://example.org', silent_mode_enabled: silent_mode) }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/gitlab/stub_requests.rb b/gems/gitlab-http/spec/gitlab/stub_requests.rb
new file mode 100644
index 00000000000..ea4a6865251
--- /dev/null
+++ b/gems/gitlab-http/spec/gitlab/stub_requests.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module StubRequests
+ IP_ADDRESS_STUB = '8.8.8.9'
+
+ # Fully stubs a request using WebMock class. This class also
+ # stubs the IP address the URL is translated to (DNS lookup).
+ #
+ # It expects the final request to go to the `ip_address` instead the given url.
+ # That's primarily a DNS rebind attack prevention of Gitlab::HTTP
+ # (see: Gitlab::HTTP_V2::UrlBlocker).
+ #
+ def stub_full_request(url, ip_address: IP_ADDRESS_STUB, port: 80, method: :get)
+ stub_dns(url, ip_address: ip_address, port: port)
+
+ url = stubbed_hostname(url, hostname: ip_address)
+ WebMock.stub_request(method, url)
+ end
+
+ def stub_dns(url, ip_address:, port: 80)
+ url = parse_url(url)
+ socket = Socket.sockaddr_in(port, ip_address)
+ addr = Addrinfo.new(socket)
+
+ # See Gitlab::UrlBlocker
+ allow(Addrinfo).to receive(:getaddrinfo)
+ .with(url.hostname, url.port, nil, :STREAM)
+ .and_return([addr])
+ end
+
+ def stub_all_dns(url, ip_address:)
+ url = URI(url)
+ port = 80 # arbitarily chosen, does not matter as we are not going to connect
+ socket = Socket.sockaddr_in(port, ip_address)
+ addr = Addrinfo.new(socket)
+
+ # See Gitlab::UrlBlocker
+ allow(Addrinfo).to receive(:getaddrinfo).and_call_original
+ allow(Addrinfo).to receive(:getaddrinfo)
+ .with(url.hostname, anything, nil, :STREAM)
+ .and_return([addr])
+ end
+
+ def stubbed_hostname(url, hostname: IP_ADDRESS_STUB)
+ url = parse_url(url)
+ url.hostname = hostname
+ url.to_s
+ end
+
+ private
+
+ def parse_url(url)
+ url.is_a?(URI) ? url : URI(url)
+ end
+ end
+end
diff --git a/gems/gitlab-http/spec/spec_helper.rb b/gems/gitlab-http/spec/spec_helper.rb
new file mode 100644
index 00000000000..a9bfc471aef
--- /dev/null
+++ b/gems/gitlab-http/spec/spec_helper.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'rails'
+require 'rspec/mocks'
+
+require 'gitlab/rspec/all'
+require 'gitlab/http_v2'
+require 'gitlab/http_v2/configuration'
+require 'gitlab/stub_requests'
+require 'webmock/rspec'
+
+ENV["RSPEC_ALLOW_INVALID_URLS"] = 'true' # rubocop: disable RSpec/EnvAssignment
+
+RSpec.configure do |config|
+ config.include StubENV
+ config.include Gitlab::StubRequests
+
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ # Disable RSpec exposing methods globally on `Module` and `main`
+ config.disable_monkey_patching!
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end
+
+Gitlab::HTTP_V2.configure do |config|
+ config.allowed_internal_uris = [
+ URI::HTTP.build(
+ scheme: 'http',
+ host: 'localhost',
+ port: '80'
+ ),
+ URI::Generic.build(
+ scheme: 'ssh',
+ host: 'localhost',
+ port: '22'
+ )
+ ]
+
+ config.log_exception_proc = ->(exception, extra_info) do
+ # no-op
+ end
+
+ config.silent_mode_log_info_proc = ->(message, http_method) do
+ # no-op
+ end
+end
diff --git a/lib/api/entities/personal_access_token.rb b/lib/api/entities/personal_access_token.rb
index 3ec91ca5fc9..b9f831021a1 100644
--- a/lib/api/entities/personal_access_token.rb
+++ b/lib/api/entities/personal_access_token.rb
@@ -13,7 +13,7 @@ module API
expose :active?, as: :active, documentation: { type: 'boolean' }
expose :expires_at, documentation:
{ type: 'dateTime', example: '2020-08-31T15:53:00.073Z' } do |personal_access_token|
- personal_access_token.expires_at ? personal_access_token.expires_at.strftime("%Y-%m-%d") : nil
+ personal_access_token.expires_at ? personal_access_token.expires_at.iso8601 : nil
end
end
end
diff --git a/lib/backup/database.rb b/lib/backup/database.rb
index 12656cb3702..a09a505e6d7 100644
--- a/lib/backup/database.rb
+++ b/lib/backup/database.rb
@@ -27,18 +27,20 @@ module Backup
def dump(destination_dir, backup_id)
FileUtils.mkdir_p(destination_dir)
- each_database_snapshot_id do |database_name, snapshot_id|
- base_model = base_models_for_backup[database_name]
+ each_database(destination_dir) do |database_name, current_db|
+ model = current_db[:model]
+ snapshot_id = current_db[:snapshot_id]
- config = base_model.connection_db_config.configuration_hash
+ pg_env = model.config[:pg_env]
+ connection = model.connection
+ active_record_config = model.config[:activerecord]
+ pg_database = active_record_config[:database]
db_file_name = file_name(destination_dir, database_name)
FileUtils.rm_f(db_file_name)
- pg_database = config[:database]
-
progress.print "Dumping PostgreSQL database #{pg_database} ... "
- pg_env(config)
+
pgsql_args = ["--clean"] # Pass '--clean' to include 'DROP TABLE' statements in the DB dump.
pgsql_args << '--if-exists'
pgsql_args << "--snapshot=#{snapshot_id}" if snapshot_id
@@ -53,11 +55,13 @@ module Backup
end
end
- success = Backup::Dump::Postgres.new.dump(pg_database, db_file_name, pgsql_args)
+ success = with_transient_pg_env(pg_env) do
+ Backup::Dump::Postgres.new.dump(pg_database, db_file_name, pgsql_args)
+ end
- base_model.connection.rollback_transaction if snapshot_id
+ connection.rollback_transaction if snapshot_id
- raise DatabaseBackupError.new(config, db_file_name) unless success
+ raise DatabaseBackupError.new(active_record_config, db_file_name) unless success
report_success(success)
progress.flush
@@ -72,8 +76,10 @@ module Backup
override :restore
def restore(destination_dir)
- base_models_for_backup.each do |database_name, base_model|
- config = base_model.connection_db_config.configuration_hash
+ base_models_for_backup.each do |database_name, _base_model|
+ backup_model = Backup::DatabaseModel.new(database_name)
+
+ config = backup_model.config[:activerecord]
db_file_name = file_name(destination_dir, database_name)
database = config[:database]
@@ -94,21 +100,23 @@ module Backup
# hanging out from a failed upgrade
drop_tables(database_name)
- decompress_rd, decompress_wr = IO.pipe
- decompress_pid = spawn(*%w(gzip -cd), out: decompress_wr, in: db_file_name)
- decompress_wr.close
-
- status, @errors =
- case config[:adapter]
- when "postgresql" then
- progress.print "Restoring PostgreSQL database #{database} ... "
- pg_env(config)
- execute_and_track_errors(pg_restore_cmd(database), decompress_rd)
- end
- decompress_rd.close
-
- Process.waitpid(decompress_pid)
- success = $?.success? && status.success?
+ pg_env = backup_model.config[:pg_env]
+ success = with_transient_pg_env(pg_env) do
+ decompress_rd, decompress_wr = IO.pipe
+ decompress_pid = spawn(*%w(gzip -cd), out: decompress_wr, in: db_file_name)
+ decompress_wr.close
+
+ status, @errors =
+ case config[:adapter]
+ when "postgresql" then
+ progress.print "Restoring PostgreSQL database #{database} ... "
+ execute_and_track_errors(pg_restore_cmd(database), decompress_rd)
+ end
+ decompress_rd.close
+
+ Process.waitpid(decompress_pid)
+ $?.success? && status.success?
+ end
if @errors.present?
progress.print "------ BEGIN ERRORS -----\n".color(:yellow)
@@ -204,30 +212,6 @@ module Backup
end
end
- def pg_env(config)
- args = {
- username: 'PGUSER',
- host: 'PGHOST',
- port: 'PGPORT',
- password: 'PGPASSWORD',
- # SSL
- sslmode: 'PGSSLMODE',
- sslkey: 'PGSSLKEY',
- sslcert: 'PGSSLCERT',
- sslrootcert: 'PGSSLROOTCERT',
- sslcrl: 'PGSSLCRL',
- sslcompression: 'PGSSLCOMPRESSION'
- }
- args.each do |opt, arg|
- # This enables the use of different PostgreSQL settings in
- # case PgBouncer is used. PgBouncer clears the search path,
- # which wreaks havoc on Rails if connections are reused.
- override = "GITLAB_BACKUP_#{arg}"
- val = ENV[override].presence || config[opt].to_s.presence
- ENV[arg] = val if val
- end
- end
-
def report_success(success)
if success
progress.puts '[DONE]'.color(:green)
@@ -251,30 +235,45 @@ module Backup
puts_time 'done'.color(:green)
end
+ def with_transient_pg_env(extended_env)
+ ENV.merge!(extended_env)
+ result = yield
+ ENV.reject! { |k, _| extended_env.key?(k) }
+
+ result
+ end
+
def pg_restore_cmd(database)
['psql', database]
end
- def each_database_snapshot_id(&block)
- @database_to_snapshot_id = {}
+ def each_database(destination_dir, &block)
+ databases = {}
+ ::Gitlab::Database::EachDatabase.each_connection(
+ only: base_models_for_backup.keys, include_shared: false
+ ) do |_connection, name|
+ next if databases[name]
+
+ backup_model = Backup::DatabaseModel.new(name)
- if @database_to_snapshot_id.empty?
- ::Gitlab::Database::EachDatabase.each_connection(
- only: base_models_for_backup.keys, include_shared: false
- ) do |connection, database_name|
- @database_to_snapshot_id[database_name] = nil
+ databases[name] = {
+ model: backup_model
+ }
- next unless Gitlab::Database.database_mode == Gitlab::Database::MODE_MULTIPLE_DATABASES
+ next unless Gitlab::Database.database_mode == Gitlab::Database::MODE_MULTIPLE_DATABASES
- Gitlab::Database::TransactionTimeoutSettings.new(connection).disable_timeouts
+ connection = backup_model.connection
+ begin
+ Gitlab::Database::TransactionTimeoutSettings.new(connection).disable_timeouts
connection.begin_transaction(isolation: :repeatable_read)
-
- @database_to_snapshot_id[database_name] = connection.select_value("SELECT pg_export_snapshot()")
+ databases[name][:snapshot_id] = connection.select_value("SELECT pg_export_snapshot()")
+ rescue ActiveRecord::ConnectionNotEstablished
+ raise Backup::DatabaseBackupError.new(backup_model.config[:activerecord], file_name(destination_dir, name))
end
end
- @database_to_snapshot_id.each(&block)
+ databases.each(&block)
end
end
end
diff --git a/lib/backup/database_model.rb b/lib/backup/database_model.rb
new file mode 100644
index 00000000000..6129a3ce891
--- /dev/null
+++ b/lib/backup/database_model.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module Backup
+ class DatabaseModel
+ SUPPORTED_OVERRIDES = {
+ username: 'PGUSER',
+ host: 'PGHOST',
+ port: 'PGPORT',
+ password: 'PGPASSWORD',
+ # SSL
+ sslmode: 'PGSSLMODE',
+ sslkey: 'PGSSLKEY',
+ sslcert: 'PGSSLCERT',
+ sslrootcert: 'PGSSLROOTCERT',
+ sslcrl: 'PGSSLCRL',
+ sslcompression: 'PGSSLCOMPRESSION'
+ }.freeze
+
+ attr_reader :config
+
+ def initialize(name)
+ configure_model(name)
+ end
+
+ def connection
+ @model.connection
+ end
+
+ private
+
+ def configure_model(name)
+ source_model = Gitlab::Database.database_base_models_with_gitlab_shared[name]
+
+ @model = backup_model_for(name)
+
+ original_config = source_model.connection_db_config.configuration_hash.dup
+
+ @config = config_for_backup(original_config)
+
+ @model.establish_connection(
+ ActiveRecord::DatabaseConfigurations::HashConfig.new(
+ source_model.connection_db_config.env_name,
+ name.to_s,
+ original_config.merge(@config[:activerecord])
+ )
+ )
+
+ Gitlab::Database::LoadBalancing::Setup.new(@model).setup
+ end
+
+ def backup_model_for(name)
+ klass_name = name.camelize
+
+ return "#{self.class.name}::#{klass_name}".constantize if self.class.const_defined?(klass_name.to_sym, false)
+
+ self.class.const_set(klass_name, Class.new(ApplicationRecord))
+ end
+
+ def config_for_backup(config)
+ db_config = {
+ activerecord: config,
+ pg_env: {}
+ }
+ SUPPORTED_OVERRIDES.each do |opt, arg|
+ # This enables the use of different PostgreSQL settings in
+ # case PgBouncer is used. PgBouncer clears the search path,
+ # which wreaks havoc on Rails if connections are reused.
+ override = "GITLAB_BACKUP_#{arg}"
+ val = ENV[override].presence || config[opt].to_s.presence
+
+ next unless val
+
+ db_config[:pg_env][arg] = val
+ db_config[:activerecord][opt] = val
+ end
+
+ db_config
+ end
+ end
+end
diff --git a/lib/gitlab/http.rb b/lib/gitlab/http.rb
index 8b19611e5c0..feb54fcca0c 100644
--- a/lib/gitlab/http.rb
+++ b/lib/gitlab/http.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
-# This class is used as a proxy for all outbounding http connection
-# coming from callbacks, services and hooks. The direct use of the HTTParty
-# is discouraged because it can lead to several security problems, like SSRF
-# calling internal IP or services.
+#
+# IMPORTANT: With the new development of the 'gitlab-http' gem (https://gitlab.com/gitlab-org/gitlab/-/issues/415686),
+# no additional change should be implemented in this class. This class will be removed after migrating all
+# the usages to the new gem.
+#
+
+require_relative 'http_connection_adapter'
+
module Gitlab
class HTTP
BlockedUrlError = Class.new(StandardError)
@@ -42,7 +46,7 @@ module Gitlab
alias_method :httparty_perform_request, :perform_request
end
- connection_adapter HTTPConnectionAdapter
+ connection_adapter ::Gitlab::HTTPConnectionAdapter
def self.perform_request(http_method, path, options, &block)
raise_if_blocked_by_silent_mode(http_method)
diff --git a/lib/gitlab/http_connection_adapter.rb b/lib/gitlab/http_connection_adapter.rb
index afb740a902b..822b8a9f8d9 100644
--- a/lib/gitlab/http_connection_adapter.rb
+++ b/lib/gitlab/http_connection_adapter.rb
@@ -18,6 +18,8 @@
# to read header data. It is a modified version of Net::BufferedIO that
# raises a timeout error if reading header data takes too much time.
+require_relative 'utils/override'
+
module Gitlab
class HTTPConnectionAdapter < HTTParty::ConnectionAdapter
extend ::Gitlab::Utils::Override
diff --git a/lib/gitlab/import_export/command_line_util.rb b/lib/gitlab/import_export/command_line_util.rb
index 924ca4e83ea..683a926f6a2 100644
--- a/lib/gitlab/import_export/command_line_util.rb
+++ b/lib/gitlab/import_export/command_line_util.rb
@@ -68,6 +68,8 @@ module Gitlab
File.open(upload_path, 'wb') do |file|
current_size = 0
+ # When migrating from Gitlab::HTTP to Gitlab:HTTP_V2, we need to pass `extra_allowed_uris` as an option
+ # instead of `allow_object_storage`.
Gitlab::HTTP.get(url, stream_body: true, allow_object_storage: true) do |fragment|
if [301, 302, 303, 307].include?(fragment.code)
Gitlab::Import::Logger.warn(message: "received redirect fragment", fragment_code: fragment.code)
diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb
index f8948c4959f..a487a0d04b8 100644
--- a/lib/gitlab/workhorse.rb
+++ b/lib/gitlab/workhorse.rb
@@ -184,11 +184,17 @@ module Gitlab
]
end
- def send_dependency(headers, url)
+ def send_dependency(headers, url, upload_config: {})
params = {
- 'Header' => headers,
- 'Url' => url
+ 'Headers' => headers.transform_values { |v| Array.wrap(v) },
+ 'Url' => url,
+ 'UploadConfig' => {
+ 'Method' => upload_config[:method],
+ 'Url' => upload_config[:url],
+ 'Headers' => (upload_config[:headers] || {}).transform_values { |v| Array.wrap(v) }
+ }.compact_blank!
}
+ params.compact_blank!
[
SEND_DATA_HEADER,
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4fe08d30f7e..9ac4b89e6af 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -19672,6 +19672,9 @@ msgstr ""
msgid "Failed to delete custom emoji. Please try again."
msgstr ""
+msgid "Failed to delete target branch rule"
+msgstr ""
+
msgid "Failed to deploy to"
msgstr ""
@@ -27100,6 +27103,9 @@ msgstr ""
msgid "Keep divergent refs"
msgstr ""
+msgid "Keep sidebar visible"
+msgstr ""
+
msgid "Kerberos access denied"
msgstr ""
@@ -44071,9 +44077,6 @@ msgstr ""
msgid "Show project milestones"
msgstr ""
-msgid "Show sidebar"
-msgstr ""
-
msgid "Show sub-group milestones"
msgstr ""
@@ -46588,6 +46591,9 @@ msgstr ""
msgid "Target branch rule created."
msgstr ""
+msgid "Target branch rule does not exist"
+msgstr ""
+
msgid "Target branch rules"
msgstr ""
@@ -54376,6 +54382,9 @@ msgstr ""
msgid "You have insufficient permissions to create an on-call schedule for this project"
msgstr ""
+msgid "You have insufficient permissions to delete a target branch rule"
+msgstr ""
+
msgid "You have insufficient permissions to manage alerts for this project"
msgstr ""
diff --git a/qa/README.md b/qa/README.md
index c1b6794ca60..15af644a4ae 100644
--- a/qa/README.md
+++ b/qa/README.md
@@ -95,29 +95,19 @@ By default tests on CI use `info` log level. `debug` level is still available in
First, follow the instructions to [install GDK](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/main/doc/index.md) as your local GitLab development environment.
-Then, navigate to the QA folder and run the following commands:
+Then, navigate to the QA folder, install the gems, and run the tests via RSpec:
```bash
cd gitlab-development-kit/gitlab/qa
bundle install
-export WEBDRIVER_HEADLESS=false
-export GITLAB_INITIAL_ROOT_PASSWORD={your current root user's password}
-```
-
-Finally, most tests that do not require special setup (or have the `:orchestrated` tag) can be run with the following command:
-
-```bash
bundle exec rspec <path/to/spec.rb>
```
-However, tests that are tagged with the `:orchestrated` tag require special setup. To run these tests, first [re-configure the IP address in GDK](https://gitlab.com/gitlab-org/gitlab-qa/-/blob/master/docs/run_qa_against_gdk.md#run-qa-tests-against-your-gdk-setup), and then run the following command:
-
-```bash
-bundle exec bin/qa Test::Instance::All {GDK IP ADDRESS}
-```
-
-- Note: If you want to run tests requiring SSH against GDK, you will need to [modify your GDK setup](https://gitlab.com/gitlab-org/gitlab-qa/blob/master/docs/run_qa_against_gdk.md).
-- Note: If this is your first time running GDK, you can use the password pre-set for `root`. [See supported GitLab environment variables](https://gitlab.com/gitlab-org/gitlab-qa/-/blob/master/docs/what_tests_can_be_run.md#supported-gitlab-environment-variables). If you have changed your `root` password, use that when exporting `GITLAB_INITIAL_ROOT_PASSWORD`.
+Note:
+- If you want to run tests requiring SSH against GDK, you will need to [modify your GDK setup](https://gitlab.com/gitlab-org/gitlab-qa/blob/master/docs/run_qa_against_gdk.md).
+- If this is your first time running GDK, you can use the password pre-set for `root`. [See supported GitLab environment variables](https://gitlab.com/gitlab-org/gitlab-qa/-/blob/master/docs/what_tests_can_be_run.md#supported-gitlab-environment-variables). If you have changed your `root` password, export the password as `GITLAB_INITIAL_ROOT_PASSWORD`.
+- By default the tests will run in a headless browser. If you'd like to watch the test exectution, you can export `WEBDRIVER_HEADLESS=false`.
+- Tests that are tagged `:orchestrated` require special setup (e.g., custom GitLab configuration, or additional services such as LDAP). All [orchestrated tests can be run via `gitlab-qa`](https://gitlab.com/gitlab-org/gitlab-qa/-/blob/master/docs/what_tests_can_be_run.md). There are also [setup instructions](https://docs.gitlab.com/ee/development/testing_guide/end_to_end/running_tests_that_require_special_setup.html) for running some of those tests against GDK or another local GitLab instance.
#### Generic command for a typical GDK installation
@@ -145,7 +135,7 @@ See the section above for situations that might require adjustment to the comman
1. Use the following command to start an instance that you can visit at `http://127.0.0.1`:
```bash
- docker run \
+ docker run \
--hostname 127.0.0.1 \
--publish 80:80 --publish 22:22 \
--name gitlab \
diff --git a/qa/lib/gitlab/page/main/sign_up.rb b/qa/lib/gitlab/page/main/sign_up.rb
index ff9a3e370f7..d4ae335babd 100644
--- a/qa/lib/gitlab/page/main/sign_up.rb
+++ b/qa/lib/gitlab/page/main/sign_up.rb
@@ -6,16 +6,15 @@ module Gitlab
class SignUp < Chemlab::Page
path '/users/sign_up'
- # TODO: Refactor data-qa-selectors to be more terse
- text_field :first_name, 'data-qa-selector': 'new_user_first_name_field'
- text_field :last_name, 'data-qa-selector': 'new_user_last_name_field'
+ text_field :first_name, 'data-testid': 'new-user-first-name-field'
+ text_field :last_name, 'data-testid': 'new-user-last-name-field'
- text_field :username, 'data-testid': 'new_user_username_field'
+ text_field :username, 'data-testid': 'new-user-username-field'
- text_field :email, 'data-qa-selector': 'new_user_email_field'
- text_field :password, 'data-qa-selector': 'new_user_password_field'
+ text_field :email, 'data-testid': 'new-user-email-field'
+ text_field :password, 'data-testid': 'new-user-password-field'
- button :register, 'data-qa-selector': 'new_user_register_button'
+ button :register, 'data-testid': 'new-user-register-button'
# Register a user
# @param [Resource::User] user the user to register
diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb
index 435d2008d87..85ef81da91b 100644
--- a/qa/qa/page/project/pipeline/index.rb
+++ b/qa/qa/page/project/pipeline/index.rb
@@ -5,23 +5,23 @@ module QA
module Project
module Pipeline
class Index < QA::Page::Base
- view 'app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_url.vue' do
+ view 'app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue' do
element :pipeline_url_link
end
- view 'app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_status_badge.vue' do
+ view 'app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue' do
element :pipeline_commit_status
end
- view 'app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue' do
+ view 'app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue' do
element :pipeline_retry_button
end
- view 'app/assets/javascripts/ci/pipeline_details/pipelines_list/components/nav_controls.vue' do
+ view 'app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue' do
element :run_pipeline_button
end
- view 'app/assets/javascripts/ci/pipeline_details/pipelines_list/components/pipelines_table.vue' do
+ view 'app/assets/javascripts/ci/common/pipelines_table.vue' do
element :pipeline_row_container
end
diff --git a/qa/qa/page/registration/sign_up.rb b/qa/qa/page/registration/sign_up.rb
index 4fedc05c702..ab3f15bb857 100644
--- a/qa/qa/page/registration/sign_up.rb
+++ b/qa/qa/page/registration/sign_up.rb
@@ -5,39 +5,39 @@ module QA
module Registration
class SignUp < Page::Base
view 'app/views/devise/shared/_signup_box.html.haml' do
- element :new_user_first_name_field
- element :new_user_last_name_field
- element :new_user_email_field
- element :new_user_password_field
- element :new_user_register_button
+ element 'new-user-first-name-field'
+ element 'new-user-last-name-field'
+ element 'new-user-email-field'
+ element 'new-user-password-field'
+ element 'new-user-register-button'
end
view 'app/helpers/registrations_helper.rb' do
- element :new_user_username_field
+ element 'new-user-username-field'
end
def fill_new_user_first_name_field(first_name)
- fill_element :new_user_first_name_field, first_name
+ fill_element 'new-user-first-name-field', first_name
end
def fill_new_user_last_name_field(last_name)
- fill_element :new_user_last_name_field, last_name
+ fill_element 'new-user-last-name-field', last_name
end
def fill_new_user_username_field(username)
- fill_element :new_user_username_field, username
+ fill_element 'new-user-username-field', username
end
def fill_new_user_email_field(email)
- fill_element :new_user_email_field, email
+ fill_element 'new-user-email-field', email
end
def fill_new_user_password_field(password)
- fill_element :new_user_password_field, password
+ fill_element 'new-user-password-field', password
end
def click_new_user_register_button
- click_element :new_user_register_button if has_element?(:new_user_register_button)
+ click_element 'new-user-register-button' if has_element?('new-user-register-button')
end
end
end
diff --git a/scripts/setup-test-env b/scripts/setup-test-env
index 50bec46b71a..a9d1be7a0ce 100755
--- a/scripts/setup-test-env
+++ b/scripts/setup-test-env
@@ -24,6 +24,7 @@ require_relative '../lib/system_check/helpers'
# Required for config/initializers/1_settings.rb
require 'omniauth'
require 'omniauth-github'
+require 'omniauth-saml'
require 'etc'
require 'gitlab/utils/all'
require 'gitlab/safe_request_store'
diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
index 3fb5e08f065..6bb791d2fd4 100644
--- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
+++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -244,7 +244,7 @@ RSpec.describe Groups::DependencyProxyForContainersController, feature_category:
subject
send_data_type, send_data = workhorse_send_data
- header, url = send_data.values_at('Header', 'Url')
+ header, url = send_data.values_at('Headers', 'Url')
expect(send_data_type).to eq('send-dependency')
expect(header).to eq(
@@ -312,7 +312,7 @@ RSpec.describe Groups::DependencyProxyForContainersController, feature_category:
subject
send_data_type, send_data = workhorse_send_data
- header, url = send_data.values_at('Header', 'Url')
+ header, url = send_data.values_at('Headers', 'Url')
expect(send_data_type).to eq('send-dependency')
expect(header).to eq("Authorization" => ["Bearer abcd1234"])
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb
index 56272f58e0d..4fe05abd73b 100644
--- a/spec/features/cycle_analytics_spec.rb
+++ b/spec/features/cycle_analytics_spec.rb
@@ -60,8 +60,8 @@ RSpec.describe 'Value Stream Analytics', :js, feature_category: :value_stream_ma
# NOTE: in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68595 travel back
# 5 days in time before we create data for these specs, to mitigate some flakiness
# So setting the date range to be the last 2 days should skip past the existing data
- from = 2.days.ago.strftime("%Y-%m-%d")
- to = 1.day.ago.strftime("%Y-%m-%d")
+ from = 2.days.ago.to_date.iso8601
+ to = 1.day.ago.to_date.iso8601
max_items_per_page = 20
around do |example|
diff --git a/spec/features/groups/dependency_proxy_for_containers_spec.rb b/spec/features/groups/dependency_proxy_for_containers_spec.rb
index c0456140291..1e15b97c5aa 100644
--- a/spec/features/groups/dependency_proxy_for_containers_spec.rb
+++ b/spec/features/groups/dependency_proxy_for_containers_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe 'Group Dependency Proxy for containers', :js, feature_category: :
include DependencyProxyHelpers
include_context 'file upload requests helpers'
+ include_context 'with a server running the dependency proxy'
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
@@ -21,17 +22,6 @@ RSpec.describe 'Group Dependency Proxy for containers', :js, feature_category: :
HTTParty.get(url, headers: headers)
end
- def run_server(handler)
- default_server = Capybara.server
-
- Capybara.server = Capybara.servers[:puma]
- server = Capybara::Server.new(handler)
- server.boot
- server
- ensure
- Capybara.server = default_server
- end
-
let_it_be(:external_server) do
handler = lambda do |env|
if env['REQUEST_PATH'] == '/token'
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_table_spec.js b/spec/frontend/ci/common/pipelines_table_spec.js
index 2ae8475f38d..26dd1a2fcc5 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_table_spec.js
+++ b/spec/frontend/ci/common/pipelines_table_spec.js
@@ -5,22 +5,22 @@ import fixture from 'test_fixtures/pipelines/pipelines.json';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue';
-import PipelineFailedJobsWidget from '~/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
-import PipelineOperations from '~/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue';
-import PipelineTriggerer from '~/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue';
-import PipelineUrl from '~/ci/pipeline_details/pipelines_list/components/pipeline_url.vue';
-import PipelinesTable from '~/ci/pipeline_details/pipelines_list/components/pipelines_table.vue';
-import PipelinesTimeago from '~/ci/pipeline_details/pipelines_list/components/time_ago.vue';
+import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue';
+import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue';
+import PipelineTriggerer from '~/ci/pipelines_page/components/pipeline_triggerer.vue';
+import PipelineUrl from '~/ci/pipelines_page/components/pipeline_url.vue';
+import PipelinesTable from '~/ci/common/pipelines_table.vue';
+import PipelinesTimeago from '~/ci/pipelines_page/components/time_ago.vue';
import {
PipelineKeyOptions,
BUTTON_TOOLTIP_RETRY,
BUTTON_TOOLTIP_CANCEL,
TRACKING_CATEGORIES,
-} from '~/ci/pipeline_details/constants';
+} from '~/ci/constants';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
-jest.mock('~/ci/pipeline_details/event_hub');
+jest.mock('~/ci/event_hub');
describe('Pipelines Table', () => {
let pipeline;
diff --git a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js
index f48340153a1..6e13658a773 100644
--- a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js
+++ b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js
@@ -6,7 +6,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import PipelineDetailsHeader from '~/ci/pipeline_details/header/pipeline_details_header.vue';
-import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/pipeline_details/constants';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import cancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql';
import deletePipelineMutation from '~/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql';
diff --git a/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js b/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js
index cb2d8ad85d5..7110a35ad4e 100644
--- a/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js
+++ b/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js
@@ -9,7 +9,7 @@ import { createAlert } from '~/alert';
import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated
import FailedJobsTable from '~/ci/pipeline_details/jobs/components/failed_jobs_table.vue';
import RetryFailedJobMutation from '~/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql';
-import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
import {
successRetryMutationResponse,
failedRetryMutationResponse,
diff --git a/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js b/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js
index 700d51930dd..0f1835b7ec8 100644
--- a/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js
+++ b/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js
@@ -2,7 +2,7 @@ import { GlTab } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import PipelineTabs from '~/ci/pipeline_details/tabs/pipeline_tabs.vue';
-import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
describe('The Pipeline Tabs', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js
index 1d44134bef8..30a0b868c5f 100644
--- a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js
+++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js
@@ -6,7 +6,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue';
-import eventHub from '~/ci/pipeline_details/event_hub';
+import eventHub from '~/ci/event_hub';
import waitForPromises from 'helpers/wait_for_promises';
import { stageReply } from './mock_data';
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js
index 558063ecba5..980a8be24ea 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ci_templates_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js
@@ -1,6 +1,6 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import CiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue';
+import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
const pipelineEditorPath = '/-/ci/editor';
const suggestedCiTemplates = [
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js
index cdd3053d66a..8620d41886e 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/ios_templates_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js
@@ -3,8 +3,8 @@ import { nextTick } from 'vue';
import { GlPopover, GlButton } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
-import IosTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue';
-import CiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue';
+import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue';
+import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
const pipelineEditorPath = '/-/ci/editor';
const registrationToken = 'SECRET_TOKEN';
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js
index 6ef37f59f66..0c42723f753 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js
@@ -2,10 +2,10 @@ import '~/commons';
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import { stubExperiments } from 'helpers/experimentation_helper';
-import EmptyState from '~/ci/pipeline_details/pipelines_list/empty_state/no_ci_empty_state.vue';
+import EmptyState from '~/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue';
import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import PipelinesCiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue';
-import IosTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ios_templates.vue';
+import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
+import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue';
describe('Pipelines Empty State', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
index 76b4cc163b2..fbef4aa08eb 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
@@ -1,8 +1,8 @@
import '~/commons';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import PipelinesCiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue';
-import CiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/ci_templates.vue';
+import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
+import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
const pipelineEditorPath = '/-/ci/editor';
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js
index cc68af4f7f3..6967a369338 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js
@@ -6,7 +6,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import FailedJobDetails from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue';
+import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue';
import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql';
import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants';
import { job } from './mock';
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js
index 6c1c5f9c223..af075b02b64 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js
@@ -6,10 +6,10 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
-import FailedJobsList from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue';
-import FailedJobDetails from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_job_details.vue';
-import * as utils from '~/ci/pipeline_details/pipelines_list/failure_widget/utils';
-import getPipelineFailedJobs from '~/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_failed_jobs.query.graphql';
+import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue';
+import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue';
+import * as utils from '~/ci/pipelines_page/components/failure_widget/utils';
+import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql';
import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock';
Vue.use(VueApollo);
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/mock.js b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js
index 318d787a984..318d787a984 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/mock.js
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js
index 5135bf57b22..e52b62feb23 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js
@@ -1,7 +1,7 @@
import { GlButton, GlCard, GlIcon, GlPopover } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import PipelineFailedJobsWidget from '~/ci/pipeline_details/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue';
-import FailedJobsList from '~/ci/pipeline_details/pipelines_list/failure_widget/failed_jobs_list.vue';
+import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue';
+import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue';
jest.mock('~/alert');
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/utils_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js
index 16a0da4e054..5755cd846ac 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/failure_widget/utils_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js
@@ -1,7 +1,4 @@
-import {
- isFailedJob,
- sortJobsByStatus,
-} from '~/ci/pipeline_details/pipelines_list/failure_widget/utils';
+import { isFailedJob, sortJobsByStatus } from '~/ci/pipelines_page/components/failure_widget/utils';
describe('isFailedJob', () => {
describe('when the job argument is undefined', () => {
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/nav_controls_spec.js b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js
index cefe0c9f0a3..f4858ac27ea 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/nav_controls_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js
@@ -1,5 +1,5 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import NavControls from '~/ci/pipeline_details/pipelines_list/components/nav_controls.vue';
+import NavControls from '~/ci/pipelines_page/components/nav_controls.vue';
describe('Pipelines Nav Controls', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js
index 87c2867b5d8..b5c9a3030e0 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_labels_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js
@@ -1,7 +1,7 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
-import PipelineLabelsComponent from '~/ci/pipeline_details/pipelines_list/components/pipeline_labels.vue';
-import { mockPipeline } from '../../mock_data';
+import PipelineLabelsComponent from '~/ci/pipelines_page/components/pipeline_labels.vue';
+import { mockPipeline } from 'jest/ci/pipeline_details/mock_data';
const projectPath = 'test/test';
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js
index 29a2d728c78..7ae21db8815 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js
@@ -17,8 +17,8 @@ import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import PipelineMultiActions, {
i18n,
-} from '~/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue';
-import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
+} from '~/ci/pipelines_page/components/pipeline_multi_actions.vue';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
describe('Pipeline Multi Actions Dropdown', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_operations_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js
index 3e2005236bb..d2eab64b317 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_operations_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js
@@ -1,8 +1,8 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import PipelinesManualActions from '~/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue';
-import PipelineMultiActions from '~/ci/pipeline_details/pipelines_list/components/pipeline_multi_actions.vue';
-import PipelineOperations from '~/ci/pipeline_details/pipelines_list/components/pipeline_operations.vue';
-import eventHub from '~/ci/pipeline_details/event_hub';
+import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue';
+import PipelineMultiActions from '~/ci/pipelines_page/components/pipeline_multi_actions.vue';
+import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue';
+import eventHub from '~/ci/event_hub';
describe('Pipeline operations', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js
index 81fed11875d..4d78a923542 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data';
-import PipelineStopModal from '~/ci/pipeline_details/pipelines_list/components/pipeline_stop_modal.vue';
+import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue';
describe('PipelineStopModal', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_triggerer_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js
index 4c8a43598ad..cb04171f031 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_triggerer_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js
@@ -1,6 +1,6 @@
import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import pipelineTriggerer from '~/ci/pipeline_details/pipelines_list/components/pipeline_triggerer.vue';
+import pipelineTriggerer from '~/ci/pipelines_page/components/pipeline_triggerer.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('Pipelines Triggerer', () => {
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_url_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js
index 78097edecd3..0ee22dda826 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipeline_url_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js
@@ -1,10 +1,14 @@
import { merge } from 'lodash';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import PipelineUrlComponent from '~/ci/pipeline_details/pipelines_list/components/pipeline_url.vue';
+import PipelineUrlComponent from '~/ci/pipelines_page/components/pipeline_url.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
-import { mockPipeline, mockPipelineBranch, mockPipelineTag } from '../../mock_data';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+import {
+ mockPipeline,
+ mockPipelineBranch,
+ mockPipelineTag,
+} from 'jest/ci/pipeline_details/mock_data';
const projectPath = 'test/test';
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_artifacts_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js
index 7ef3513cbce..557403b3de9 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_artifacts_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js
@@ -5,7 +5,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import PipelineArtifacts from '~/ci/pipeline_details/pipelines_list/components/pipelines_artifacts.vue';
+import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue';
describe('Pipelines Artifacts dropdown', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js
index 0b62920e01b..4cd85b86e31 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js
@@ -5,13 +5,13 @@ import { nextTick } from 'vue';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
-import PipelinesFilteredSearch from '~/ci/pipeline_details/pipelines_list/components/pipelines_filtered_search.vue';
+import PipelinesFilteredSearch from '~/ci/pipelines_page/components/pipelines_filtered_search.vue';
import {
FILTERED_SEARCH_TERM,
OPERATORS_IS,
} from '~/vue_shared/components/filtered_search_bar/constants';
-import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
-import { users, mockSearch, branches, tags } from '../../mock_data';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+import { users, mockSearch, branches, tags } from 'jest/ci/pipeline_details/mock_data';
describe('Pipelines filtered search', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js
index c0ea0fda4df..a24e136f1ff 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js
@@ -11,9 +11,9 @@ import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import PipelinesManualActions from '~/ci/pipeline_details/pipelines_list/components/pipelines_manual_actions.vue';
-import getPipelineActionsQuery from '~/ci/pipeline_details/pipelines_list/graphql/queries/get_pipeline_actions.query.graphql';
-import { TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
+import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue';
+import getPipelineActionsQuery from '~/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
Vue.use(VueApollo);
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/components/time_ago_spec.js b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js
index e651427fb78..f7203f8d1b4 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/components/time_ago_spec.js
+++ b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js
@@ -1,7 +1,7 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import TimeAgo from '~/ci/pipeline_details/pipelines_list/components/time_ago.vue';
+import TimeAgo from '~/ci/pipelines_page/components/time_ago.vue';
describe('Timeago component', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js
index 5790b753706..5d1f431e57c 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/pipelines_spec.js
+++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js
@@ -24,11 +24,11 @@ import { createAlert, VARIANT_WARNING } from '~/alert';
import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
-import NavigationControls from '~/ci/pipeline_details/pipelines_list/components/nav_controls.vue';
-import PipelinesComponent from '~/ci/pipeline_details/pipelines_list/pipelines.vue';
-import PipelinesCiTemplates from '~/ci/pipeline_details/pipelines_list/empty_state/pipelines_ci_templates.vue';
-import PipelinesTableComponent from '~/ci/pipeline_details/pipelines_list/components/pipelines_table.vue';
-import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/pipeline_details/constants';
+import NavigationControls from '~/ci/pipelines_page/components/nav_controls.vue';
+import PipelinesComponent from '~/ci/pipelines_page/pipelines.vue';
+import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
+import PipelinesTableComponent from '~/ci/common/pipelines_table.vue';
+import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants';
import Store from '~/ci/pipeline_details/stores/pipelines_store';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
@@ -38,7 +38,7 @@ import {
} from 'jest/issues/list/mock_data';
import { stageReply } from 'jest/ci/pipeline_mini_graph/mock_data';
-import { users, mockSearch, branches } from '../mock_data';
+import { users, mockSearch, branches } from '../pipeline_details/mock_data';
jest.mock('@sentry/browser');
jest.mock('~/alert');
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js
index effcb533ffa..ea615d85c4b 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token_spec.js
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js
@@ -3,8 +3,8 @@ import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
-import PipelineBranchNameToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_branch_name_token.vue';
-import { branches, mockBranchesAfterMap } from '../../mock_data';
+import PipelineBranchNameToken from '~/ci/pipelines_page/tokens/pipeline_branch_name_token.vue';
+import { branches, mockBranchesAfterMap } from 'jest/ci/pipeline_details/mock_data';
describe('Pipeline Branch Name Token', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js
index 180fdee8353..0ea2b641b33 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token_spec.js
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js
@@ -1,8 +1,8 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipeline_details/pipelines_list/tokens/constants';
+import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants';
import { stubComponent } from 'helpers/stub_component';
-import PipelineSourceToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_source_token.vue';
+import PipelineSourceToken from '~/ci/pipelines_page/tokens/pipeline_source_token.vue';
describe('Pipeline Source Token', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js
index 4b9d4fb7a94..b8f98666438 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token_spec.js
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js
@@ -1,7 +1,7 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
-import PipelineStatusToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_status_token.vue';
+import PipelineStatusToken from '~/ci/pipelines_page/tokens/pipeline_status_token.vue';
import {
TOKEN_TITLE_STATUS,
TOKEN_TYPE_STATUS,
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js
index d3eae14608d..d23d9f07df3 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token_spec.js
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js
@@ -1,8 +1,8 @@
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Api from '~/api';
-import PipelineTagNameToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_tag_name_token.vue';
-import { tags, mockTagsAfterMap } from '../../mock_data';
+import PipelineTagNameToken from '~/ci/pipelines_page/tokens/pipeline_tag_name_token.vue';
+import { tags, mockTagsAfterMap } from 'jest/ci/pipeline_details/mock_data';
describe('Pipeline Branch Name Token', () => {
let wrapper;
diff --git a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js
index 2eab2cd2ef2..eccb90b0c94 100644
--- a/spec/frontend/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token_spec.js
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js
@@ -2,8 +2,8 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from
import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import Api from '~/api';
-import PipelineTriggerAuthorToken from '~/ci/pipeline_details/pipelines_list/tokens/pipeline_trigger_author_token.vue';
-import { users } from '../../mock_data';
+import PipelineTriggerAuthorToken from '~/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue';
+import { users } from 'jest/ci/pipeline_details/mock_data';
describe('Pipeline Trigger Author Token', () => {
let wrapper;
diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb
index 61433363ce7..151d4a763c0 100644
--- a/spec/frontend/fixtures/pipelines.rb
+++ b/spec/frontend/fixtures/pipelines.rb
@@ -71,7 +71,7 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co
end
let_it_be(:query) do
- get_graphql_query_as_string("ci/pipeline_details/pipelines_list/graphql/queries/#{get_pipeline_actions_query}")
+ get_graphql_query_as_string("ci/pipelines_page/graphql/queries/#{get_pipeline_actions_query}")
end
it "#{fixtures_path}#{get_pipeline_actions_query}.json" do
diff --git a/spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js b/spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js
new file mode 100644
index 00000000000..75b834ee7c9
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js
@@ -0,0 +1,213 @@
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import {
+ SUPER_SIDEBAR_PEEK_OPEN_DELAY,
+ SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+ JS_TOGGLE_EXPAND_CLASS,
+ SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE,
+} from '~/super_sidebar/constants';
+import SidebarHoverPeek from '~/super_sidebar/components/sidebar_hover_peek_behavior.vue';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { moveMouse, mouseEnter, mouseLeave, moveMouseOutOfDocument } from '../mocks';
+
+// This is measured at runtime in the browser, but statically defined here
+// since Jest does not do layout/styling.
+const X_SIDEBAR_EDGE = 10;
+
+jest.mock('~/lib/utils/css_utils', () => ({
+ getCssClassDimensions: () => ({ width: X_SIDEBAR_EDGE }),
+}));
+
+describe('SidebarHoverPeek component', () => {
+ let wrapper;
+ let toggle;
+ let trackingSpy = null;
+
+ const createComponent = (props = { isMouseOverSidebar: false }) => {
+ wrapper = mount(SidebarHoverPeek, {
+ propsData: props,
+ });
+
+ return nextTick();
+ };
+
+ const lastNChangeEvents = (n = 1) => wrapper.emitted('change').slice(-n).flat();
+
+ beforeEach(() => {
+ toggle = document.createElement('button');
+ toggle.classList.add(JS_TOGGLE_EXPAND_CLASS);
+ document.body.appendChild(toggle);
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ // We destroy the wrapper ourselves as that needs to happen before the toggle is removed.
+ // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy
+ wrapper.destroy();
+ toggle?.remove();
+ });
+
+ it('begins in the closed state', async () => {
+ await createComponent();
+
+ expect(lastNChangeEvents(Infinity)).toEqual([STATE_CLOSED]);
+ });
+
+ describe('when mouse enters the toggle', () => {
+ beforeEach(async () => {
+ await createComponent();
+ mouseEnter(toggle);
+ });
+
+ it('does not emit duplicate events in a region', () => {
+ mouseEnter(toggle);
+
+ expect(lastNChangeEvents(Infinity)).toEqual([STATE_CLOSED, STATE_WILL_OPEN]);
+ });
+
+ it('transitions to will-open when hovering the toggle', () => {
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]);
+ });
+
+ describe('when transitioning away from the will-open state', () => {
+ beforeEach(() => {
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1);
+ });
+
+ it('transitions to open after delay', () => {
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]);
+
+ jest.advanceTimersByTime(1);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_OPEN]);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_hover_peek', {
+ label: 'nav_sidebar_toggle',
+ property: 'nav_sidebar',
+ });
+ });
+
+ it('cancels transition to open if mouse out of toggle', () => {
+ mouseLeave(toggle);
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(3)).toEqual([STATE_WILL_OPEN, STATE_WILL_CLOSE, STATE_CLOSED]);
+ });
+
+ it('transitions to closed if cursor leaves document', () => {
+ moveMouseOutOfDocument();
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_CLOSED]);
+ });
+ });
+
+ describe('when transitioning away from the will-close state', () => {
+ beforeEach(() => {
+ jest.runOnlyPendingTimers();
+ moveMouse(X_SIDEBAR_EDGE);
+ jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1);
+ });
+
+ it('transitions to closed after delay', () => {
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_CLOSE]);
+
+ jest.advanceTimersByTime(1);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_WILL_CLOSE, STATE_CLOSED]);
+ });
+
+ it('cancels transition to close if mouse moves back to toggle', () => {
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_CLOSE]);
+
+ mouseEnter(toggle);
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(4)).toEqual([
+ STATE_OPEN,
+ STATE_WILL_CLOSE,
+ STATE_WILL_OPEN,
+ STATE_OPEN,
+ ]);
+ });
+ });
+
+ describe('when transitioning away from the open state', () => {
+ beforeEach(() => {
+ jest.runOnlyPendingTimers();
+ });
+
+ it('transitions to will-close if mouse out of sidebar region', () => {
+ expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]);
+
+ moveMouse(X_SIDEBAR_EDGE);
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_WILL_CLOSE]);
+ });
+
+ it('transitions to will-close if cursor leaves document', () => {
+ moveMouseOutOfDocument();
+
+ expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_WILL_CLOSE]);
+ });
+ });
+
+ it('cleans up its mouseleave listener before destroy', () => {
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]);
+
+ wrapper.destroy();
+ mouseLeave(toggle);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]);
+ });
+
+ it('cleans up its timers before destroy', () => {
+ wrapper.destroy();
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]);
+ });
+
+ it('cleans up document mouseleave listener before destroy', () => {
+ mouseEnter(toggle);
+
+ wrapper.destroy();
+
+ moveMouseOutOfDocument();
+
+ expect(lastNChangeEvents(1)).not.toEqual([STATE_CLOSED]);
+ });
+ });
+
+ describe('when mouse is over sidebar child element', () => {
+ beforeEach(async () => {
+ await createComponent({ isMouseOverSidebar: true });
+ });
+
+ it('does not transition to will-close or closed when mouse is over sidebar child element', () => {
+ mouseEnter(toggle);
+ jest.runOnlyPendingTimers();
+ mouseLeave(toggle);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]);
+ });
+ });
+
+ it('cleans up its mouseenter listener before destroy', async () => {
+ await createComponent();
+
+ mouseLeave(toggle);
+ jest.runOnlyPendingTimers();
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_CLOSED]);
+
+ wrapper.destroy();
+ mouseEnter(toggle);
+
+ expect(lastNChangeEvents(1)).toEqual([STATE_CLOSED]);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js
index 94ef072a951..90a950c5f35 100644
--- a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js
+++ b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js
@@ -2,14 +2,14 @@ import { mount } from '@vue/test-utils';
import {
SUPER_SIDEBAR_PEEK_OPEN_DELAY,
SUPER_SIDEBAR_PEEK_CLOSE_DELAY,
+ SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE,
} from '~/super_sidebar/constants';
-import SidebarPeek, {
- STATE_CLOSED,
- STATE_WILL_OPEN,
- STATE_OPEN,
- STATE_WILL_CLOSE,
-} from '~/super_sidebar/components/sidebar_peek_behavior.vue';
+import SidebarPeek from '~/super_sidebar/components/sidebar_peek_behavior.vue';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { moveMouse, moveMouseOutOfDocument } from '../mocks';
// These are measured at runtime in the browser, but statically defined here
// since Jest does not do layout/styling.
@@ -41,19 +41,6 @@ describe('SidebarPeek component', () => {
});
};
- const moveMouse = (clientX) => {
- const event = new MouseEvent('mousemove', {
- clientX,
- });
-
- document.dispatchEvent(event);
- };
-
- const moveMouseOutOfDocument = () => {
- const event = new MouseEvent('mouseleave');
- document.documentElement.dispatchEvent(event);
- };
-
const lastNChangeEvents = (n = 1) => wrapper.emitted('change').slice(-n).flat();
beforeEach(() => {
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
index 90f9c05342a..1371f8f00a7 100644
--- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js
+++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js
@@ -4,15 +4,17 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue';
import HelpCenter from '~/super_sidebar/components/help_center.vue';
import UserBar from '~/super_sidebar/components/user_bar.vue';
-import SidebarPeekBehavior, {
- STATE_CLOSED,
- STATE_WILL_OPEN,
- STATE_OPEN,
- STATE_WILL_CLOSE,
-} from '~/super_sidebar/components/sidebar_peek_behavior.vue';
+import SidebarPeekBehavior from '~/super_sidebar/components/sidebar_peek_behavior.vue';
+import SidebarHoverPeekBehavior from '~/super_sidebar/components/sidebar_hover_peek_behavior.vue';
import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue';
import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue';
-import { sidebarState } from '~/super_sidebar/constants';
+import {
+ sidebarState,
+ SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN,
+ SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE,
+} from '~/super_sidebar/constants';
import {
toggleSuperSidebarCollapsed,
isCollapsed,
@@ -37,6 +39,7 @@ const TrialStatusPopoverStub = {
};
const peekClass = 'super-sidebar-peek';
+const hasPeekedClass = 'super-sidebar-has-peeked';
const peekHintClass = 'super-sidebar-peek-hint';
describe('SuperSidebar component', () => {
@@ -48,6 +51,7 @@ describe('SuperSidebar component', () => {
const findHelpCenter = () => wrapper.findComponent(HelpCenter);
const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget);
const findPeekBehavior = () => wrapper.findComponent(SidebarPeekBehavior);
+ const findHoverPeekBehavior = () => wrapper.findComponent(SidebarHoverPeekBehavior);
const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId);
const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId);
const findSidebarMenu = () => wrapper.findComponent(SidebarMenu);
@@ -171,10 +175,11 @@ describe('SuperSidebar component', () => {
expect(findTrialStatusPopover().exists()).toBe(false);
});
- it('does not have peek behavior', () => {
+ it('does not have peek behaviors', () => {
createWrapper();
expect(findPeekBehavior().exists()).toBe(false);
+ expect(findHoverPeekBehavior().exists()).toBe(false);
});
it('renders the context header', () => {
@@ -216,6 +221,7 @@ describe('SuperSidebar component', () => {
expect(findSidebar().attributes('inert')).toBe('inert');
expect(findSidebar().classes()).not.toContain(peekHintClass);
+ expect(findSidebar().classes()).not.toContain(hasPeekedClass);
expect(findSidebar().classes()).not.toContain(peekClass);
});
@@ -227,6 +233,7 @@ describe('SuperSidebar component', () => {
expect(findSidebar().attributes('inert')).toBe('inert');
expect(findSidebar().classes()).toContain(peekHintClass);
+ expect(findSidebar().classes()).toContain(hasPeekedClass);
expect(findSidebar().classes()).not.toContain(peekClass);
});
@@ -241,9 +248,23 @@ describe('SuperSidebar component', () => {
expect(findSidebar().attributes('inert')).toBe(undefined);
expect(findSidebar().classes()).toContain(peekClass);
expect(findSidebar().classes()).not.toContain(peekHintClass);
+ expect(findHoverPeekBehavior().exists()).toBe(false);
},
);
+ it(`makes sidebar interactive and visible when hover peek state is ${STATE_OPEN}`, async () => {
+ createWrapper({ sidebarState: { isCollapsed: true, isPeekable: true } });
+
+ findHoverPeekBehavior().vm.$emit('change', STATE_OPEN);
+ await nextTick();
+
+ expect(findSidebar().attributes('inert')).toBe(undefined);
+ expect(findSidebar().classes()).toContain(peekClass);
+ expect(findSidebar().classes()).toContain(hasPeekedClass);
+ expect(findSidebar().classes()).not.toContain(peekHintClass);
+ expect(findPeekBehavior().exists()).toBe(false);
+ });
+
it('keeps track of if sidebar has mouseover or not', async () => {
createWrapper({ sidebarState: { isCollapsed: false, isPeekable: true } });
expect(findPeekBehavior().props('isMouseOverSidebar')).toBe(false);
diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
index 5a659c2a89a..1f2e5602d10 100644
--- a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
+++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js
@@ -1,6 +1,5 @@
import { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -46,31 +45,29 @@ describe('SuperSidebarToggle component', () => {
expect(findButton().attributes('aria-expanded')).toBe('true');
});
- it('has aria-expanded as false when collapsed', () => {
- createWrapper({ sidebarState: { isCollapsed: true } });
- expect(findButton().attributes('aria-expanded')).toBe('false');
- });
+ it.each(['isCollapsed', 'isPeek', 'isHoverPeek'])(
+ 'has aria-expanded as false when %s is `true`',
+ (stateProp) => {
+ createWrapper({ sidebarState: { [stateProp]: true } });
+ expect(findButton().attributes('aria-expanded')).toBe('false');
+ },
+ );
it('has aria-label attribute', () => {
createWrapper();
- expect(findButton().attributes('aria-label')).toBe(__('Primary navigation sidebar'));
- });
-
- it('is disabled when isPeek is true', () => {
- createWrapper({ sidebarState: { isPeek: true } });
- expect(findButton().attributes('disabled')).toBeDefined();
+ expect(findButton().attributes('aria-label')).toBe('Primary navigation sidebar');
});
});
describe('tooltip', () => {
it('displays collapse when expanded', () => {
createWrapper();
- expect(getTooltip().title).toBe(__('Hide sidebar'));
+ expect(getTooltip().title).toBe('Hide sidebar');
});
it('displays expand when collapsed', () => {
createWrapper({ sidebarState: { isCollapsed: true } });
- expect(getTooltip().title).toBe(__('Show sidebar'));
+ expect(getTooltip().title).toBe('Keep sidebar visible');
});
});
diff --git a/spec/frontend/super_sidebar/mocks.js b/spec/frontend/super_sidebar/mocks.js
new file mode 100644
index 00000000000..d13e5f1f361
--- /dev/null
+++ b/spec/frontend/super_sidebar/mocks.js
@@ -0,0 +1,24 @@
+export const moveMouse = (clientX) => {
+ const event = new MouseEvent('mousemove', {
+ clientX,
+ });
+
+ document.dispatchEvent(event);
+};
+
+export const mouseEnter = (el) => {
+ const event = new MouseEvent('mouseenter');
+
+ el.dispatchEvent(event);
+};
+
+export const mouseLeave = (el) => {
+ const event = new MouseEvent('mouseleave');
+
+ el.dispatchEvent(event);
+};
+
+export const moveMouseOutOfDocument = () => {
+ const event = new MouseEvent('mouseleave');
+ document.documentElement.dispatchEvent(event);
+};
diff --git a/spec/lib/backup/database_model_spec.rb b/spec/lib/backup/database_model_spec.rb
new file mode 100644
index 00000000000..5758ad2c1aa
--- /dev/null
+++ b/spec/lib/backup/database_model_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Backup::DatabaseModel, :reestablished_active_record_base, feature_category: :backup_restore do
+ let(:gitlab_database_name) { 'main' }
+
+ describe '#connection' do
+ subject { described_class.new(gitlab_database_name).connection }
+
+ it 'an instance of a ActiveRecord::Base.connection' do
+ subject.is_a? ActiveRecord::Base.connection.class # rubocop:disable Database/MultipleDatabases
+ end
+ end
+
+ describe '#config' do
+ let(:application_config) do
+ {
+ adapter: 'postgresql',
+ host: 'some_host',
+ port: '5432'
+ }
+ end
+
+ subject { described_class.new(gitlab_database_name).config }
+
+ before do
+ allow(
+ Gitlab::Database.database_base_models_with_gitlab_shared[gitlab_database_name].connection_db_config
+ ).to receive(:configuration_hash).and_return(application_config)
+ end
+
+ context 'when no GITLAB_BACKUP_PG* variables are set' do
+ it 'ActiveRecord backup configuration is expected to equal application configuration' do
+ expect(subject[:activerecord]).to eq(application_config)
+ end
+
+ it 'PostgreSQL ENV is expected to equal application configuration' do
+ expect(subject[:pg_env]).to eq(
+ {
+ 'PGHOST' => application_config[:host],
+ 'PGPORT' => application_config[:port]
+ }
+ )
+ end
+ end
+
+ context 'when GITLAB_BACKUP_PG* variables are set' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:env_variable, :overridden_value) do
+ 'GITLAB_BACKUP_PGHOST' | 'test.invalid.'
+ 'GITLAB_BACKUP_PGUSER' | 'some_user'
+ 'GITLAB_BACKUP_PGPORT' | '1543'
+ 'GITLAB_BACKUP_PGPASSWORD' | 'secret'
+ 'GITLAB_BACKUP_PGSSLMODE' | 'allow'
+ 'GITLAB_BACKUP_PGSSLKEY' | 'some_key'
+ 'GITLAB_BACKUP_PGSSLCERT' | '/path/to/cert'
+ 'GITLAB_BACKUP_PGSSLROOTCERT' | '/path/to/root/cert'
+ 'GITLAB_BACKUP_PGSSLCRL' | '/path/to/crl'
+ 'GITLAB_BACKUP_PGSSLCOMPRESSION' | '1'
+ end
+
+ with_them do
+ let(:pg_env) { env_variable[/GITLAB_BACKUP_(\w+)/, 1] }
+ let(:active_record_key) { described_class::SUPPORTED_OVERRIDES.invert[pg_env] }
+
+ before do
+ stub_env(env_variable, overridden_value)
+ end
+
+ it 'ActiveRecord backup configuration overrides application configuration' do
+ expect(subject[:activerecord]).to eq(application_config.merge(active_record_key => overridden_value))
+ end
+
+ it 'PostgreSQL ENV overrides application configuration' do
+ expect(subject[:pg_env]).to include({ pg_env => overridden_value })
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/backup/database_spec.rb b/spec/lib/backup/database_spec.rb
index 61e6c59a1a5..a09327b7ca7 100644
--- a/spec/lib/backup/database_spec.rb
+++ b/spec/lib/backup/database_spec.rb
@@ -8,7 +8,7 @@ RSpec.configure do |rspec|
end
end
-RSpec.describe Backup::Database, feature_category: :backup_restore do
+RSpec.describe Backup::Database, :reestablished_active_record_base, feature_category: :backup_restore do
let(:progress) { StringIO.new }
let(:output) { progress.string }
let(:one_database_configured?) { base_models_for_backup.one? }
@@ -37,13 +37,6 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
subject { described_class.new(progress, force: force) }
- before do
- base_models_for_backup.each do |_, base_model|
- base_model.connection.rollback_transaction unless base_model.connection.open_transactions.zero?
- allow(base_model.connection).to receive(:execute).and_call_original
- end
- end
-
it 'creates gzipped database dumps' do
Dir.mktmpdir do |dir|
subject.dump(dir, backup_id)
@@ -62,14 +55,15 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
it 'uses snapshots' do
Dir.mktmpdir do |dir|
- base_model = Gitlab::Database.database_base_models['main']
- expect(base_model.connection).to receive(:begin_transaction).with(
- isolation: :repeatable_read
- ).and_call_original
- expect(base_model.connection).to receive(:select_value).with(
- "SELECT pg_export_snapshot()"
- ).and_call_original
- expect(base_model.connection).to receive(:rollback_transaction).and_call_original
+ expect_next_instances_of(Backup::DatabaseModel, 2) do |adapter|
+ expect(adapter.connection).to receive(:begin_transaction).with(
+ isolation: :repeatable_read
+ ).and_call_original
+ expect(adapter.connection).to receive(:select_value).with(
+ "SELECT pg_export_snapshot()"
+ ).and_call_original
+ expect(adapter.connection).to receive(:rollback_transaction).and_call_original
+ end
subject.dump(dir, backup_id)
end
@@ -95,7 +89,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
it 'does not use snapshots' do
Dir.mktmpdir do |dir|
- base_model = Gitlab::Database.database_base_models['main']
+ base_model = Backup::DatabaseModel.new('main')
expect(base_model.connection).not_to receive(:begin_transaction).with(
isolation: :repeatable_read
).and_call_original
@@ -111,7 +105,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
describe 'pg_dump arguments' do
let(:snapshot_id) { 'fake_id' }
- let(:pg_args) do
+ let(:default_pg_args) do
args = [
'--clean',
'--if-exists'
@@ -130,24 +124,35 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
before do
allow(Backup::Dump::Postgres).to receive(:new).and_return(dumper)
allow(dumper).to receive(:dump).with(any_args).and_return(true)
+ end
- base_models_for_backup.each do |_, base_model|
- allow(base_model.connection).to receive(:select_value).with(
- "SELECT pg_export_snapshot()"
- ).and_return(snapshot_id)
+ shared_examples 'pg_dump arguments' do
+ it 'calls Backup::Dump::Postgres with correct pg_dump arguments' do
+ number_of_databases = base_models_for_backup.count
+ if number_of_databases > 1
+ expect_next_instances_of(Backup::DatabaseModel, number_of_databases) do |model|
+ expect(model.connection).to receive(:select_value).with(
+ "SELECT pg_export_snapshot()"
+ ).and_return(snapshot_id)
+ end
+ end
+
+ expect(dumper).to receive(:dump).with(anything, anything, expected_pg_args)
+
+ subject.dump(destination_dir, backup_id)
end
end
- it 'calls Backup::Dump::Postgres with correct pg_dump arguments' do
- expect(dumper).to receive(:dump).with(anything, anything, pg_args)
+ context 'when no PostgreSQL schemas are specified' do
+ let(:expected_pg_args) { default_pg_args }
- subject.dump(destination_dir, backup_id)
+ include_examples 'pg_dump arguments'
end
context 'when a PostgreSQL schema is used' do
let(:schema) { 'gitlab' }
- let(:additional_args) do
- pg_args + ['-n', schema] + Gitlab::Database::EXTRA_SCHEMAS.flat_map do |schema|
+ let(:expected_pg_args) do
+ default_pg_args + ['-n', schema] + Gitlab::Database::EXTRA_SCHEMAS.flat_map do |schema|
['-n', schema.to_s]
end
end
@@ -156,11 +161,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
allow(Gitlab.config.backup).to receive(:pg_schema).and_return(schema)
end
- it 'calls Backup::Dump::Postgres with correct pg_dump arguments' do
- expect(dumper).to receive(:dump).with(anything, anything, additional_args)
-
- subject.dump(destination_dir, backup_id)
- end
+ include_examples 'pg_dump arguments'
end
end
@@ -180,6 +181,25 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
end
end
end
+
+ context 'when using GITLAB_BACKUP_* environment variables' do
+ before do
+ stub_env('GITLAB_BACKUP_PGHOST', 'test.invalid.')
+ end
+
+ it 'will override database.yml configuration' do
+ # Expect an error because we can't connect to test.invalid.
+ expect do
+ Dir.mktmpdir { |dir| subject.dump(dir, backup_id) }
+ end.to raise_error(Backup::DatabaseBackupError)
+
+ expect do
+ ApplicationRecord.connection.select_value('select 1')
+ end.not_to raise_error
+
+ expect(ENV['PGHOST']).to be_nil
+ end
+ end
end
describe '#restore' do
@@ -288,7 +308,7 @@ RSpec.describe Backup::Database, feature_category: :backup_restore do
expect(Rake::Task['gitlab:db:drop_tables:main']).to receive(:invoke)
end
- expect(ENV).to receive(:[]=).with('PGHOST', 'test.example.com')
+ expect(ENV).to receive(:merge!).with(hash_including { 'PGHOST' => 'test.example.com' })
expect(ENV).not_to receive(:[]=).with('PGPASSWORD', anything)
subject.restore(backup_dir)
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index f726e0ff998..5deca25956d 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Workhorse do
+RSpec.describe Gitlab::Workhorse, feature_category: :shared do
let_it_be(:project) { create(:project, :repository) }
let(:features) { { 'gitaly-feature-enforce-requests-limits' => 'true' } }
@@ -552,18 +552,53 @@ RSpec.describe Gitlab::Workhorse do
describe '.send_dependency' do
let(:headers) { { Accept: 'foo', Authorization: 'Bearer asdf1234' } }
let(:url) { 'https://foo.bar.com/baz' }
+ let(:upload_method) { nil }
+ let(:upload_url) { nil }
+ let(:upload_headers) { {} }
+ let(:upload_config) { { method: upload_method, headers: upload_headers, url: upload_url }.compact_blank! }
- subject { described_class.send_dependency(headers, url) }
+ subject { described_class.send_dependency(headers, url, upload_config: upload_config) }
- it 'sets the header correctly', :aggregate_failures do
- key, command, params = decode_workhorse_header(subject)
+ shared_examples 'setting the header correctly' do |ensure_upload_config_field: nil|
+ it 'sets the header correctly' do
+ key, command, params = decode_workhorse_header(subject)
+ expected_params = {
+ 'Headers' => headers.transform_values { |v| Array.wrap(v) },
+ 'Url' => url,
+ 'UploadConfig' => {
+ 'Method' => upload_method,
+ 'Url' => upload_url,
+ 'Headers' => upload_headers.transform_values { |v| Array.wrap(v) }
+ }.compact_blank!
+ }
+ expected_params.compact_blank!
- expect(key).to eq("Gitlab-Workhorse-Send-Data")
- expect(command).to eq("send-dependency")
- expect(params).to eq({
- 'Header' => headers,
- 'Url' => url
- }.deep_stringify_keys)
+ expect(key).to eq("Gitlab-Workhorse-Send-Data")
+ expect(command).to eq("send-dependency")
+ expect(params).to eq(expected_params.deep_stringify_keys)
+
+ expect(params.dig('UploadConfig', ensure_upload_config_field)).to be_present if ensure_upload_config_field
+ end
+ end
+
+ it_behaves_like 'setting the header correctly'
+
+ context 'overriding the method' do
+ let(:upload_method) { 'PUT' }
+
+ it_behaves_like 'setting the header correctly', ensure_upload_config_field: 'Method'
+ end
+
+ context 'overriding the upload url' do
+ let(:upload_url) { 'https://test.dev' }
+
+ it_behaves_like 'setting the header correctly', ensure_upload_config_field: 'Url'
+ end
+
+ context 'with upload headers set' do
+ let(:upload_headers) { { 'Private-Token' => '1234567890' } }
+
+ it_behaves_like 'setting the header correctly', ensure_upload_config_field: 'Headers'
end
end
diff --git a/spec/support/helpers/sign_up_helpers.rb b/spec/support/helpers/sign_up_helpers.rb
index 258a1e5456f..6259467232c 100644
--- a/spec/support/helpers/sign_up_helpers.rb
+++ b/spec/support/helpers/sign_up_helpers.rb
@@ -22,6 +22,6 @@ module SignUpHelpers
private
def expect_username_to_be_validated
- expect(page).to have_selector('[data-testid="new_user_username_field"].gl-field-success-outline')
+ expect(page).to have_selector('[data-testid="new-user-username-field"].gl-field-success-outline')
end
end
diff --git a/spec/support/shared_contexts/dependency_proxy_shared_context.rb b/spec/support/shared_contexts/dependency_proxy_shared_context.rb
new file mode 100644
index 00000000000..02625722a8c
--- /dev/null
+++ b/spec/support/shared_contexts/dependency_proxy_shared_context.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'with a server running the dependency proxy' do
+ def run_server(handler)
+ default_server = Capybara.server
+
+ Capybara.server = Capybara.servers[:puma]
+ server = Capybara::Server.new(handler)
+ server.boot
+ server
+ ensure
+ Capybara.server = default_server
+ end
+end
diff --git a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
index 74dbec063e0..625f16824b4 100644
--- a/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
+++ b/spec/support/shared_examples/requests/access_tokens_controller_shared_examples.rb
@@ -72,7 +72,7 @@ RSpec.shared_examples 'GET access tokens are paginated and ordered' do
first_token = assigns(:active_access_tokens).first.as_json
expect(first_token['name']).to eq("Token1")
- expect(first_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
+ expect(first_token['expires_at']).to eq(expires_1_day_from_now.iso8601)
end
it "orders tokens on id in case token has same expires_at" do
@@ -82,11 +82,11 @@ RSpec.shared_examples 'GET access tokens are paginated and ordered' do
first_token = assigns(:active_access_tokens).first.as_json
expect(first_token['name']).to eq("Token3")
- expect(first_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
+ expect(first_token['expires_at']).to eq(expires_1_day_from_now.iso8601)
second_token = assigns(:active_access_tokens).second.as_json
expect(second_token['name']).to eq("Token1")
- expect(second_token['expires_at']).to eq(expires_1_day_from_now.strftime("%Y-%m-%d"))
+ expect(second_token['expires_at']).to eq(expires_1_day_from_now.iso8601)
end
end
diff --git a/workhorse/internal/dependencyproxy/dependencyproxy.go b/workhorse/internal/dependencyproxy/dependencyproxy.go
index e170b001806..dbea3c29aec 100644
--- a/workhorse/internal/dependencyproxy/dependencyproxy.go
+++ b/workhorse/internal/dependencyproxy/dependencyproxy.go
@@ -23,8 +23,15 @@ type Injector struct {
}
type entryParams struct {
- Url string
- Header http.Header
+ Url string
+ Headers http.Header
+ UploadConfig uploadConfig
+}
+
+type uploadConfig struct {
+ Headers http.Header
+ Method string
+ Url string
}
type nullResponseWriter struct {
@@ -55,7 +62,13 @@ func (p *Injector) SetUploadHandler(uploadHandler http.Handler) {
}
func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData string) {
- dependencyResponse, err := p.fetchUrl(r.Context(), sendData)
+ params, err := p.unpackParams(sendData)
+ if err != nil {
+ fail.Request(w, r, err)
+ return
+ }
+
+ dependencyResponse, err := p.fetchUrl(r.Context(), params)
if err != nil {
fail.Request(w, r, err)
return
@@ -70,11 +83,10 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin
w.Header().Set("Content-Length", dependencyResponse.Header.Get("Content-Length"))
teeReader := io.TeeReader(dependencyResponse.Body, w)
- saveFileRequest, err := http.NewRequestWithContext(r.Context(), "POST", r.URL.String()+"/upload", teeReader)
+ saveFileRequest, err := p.newUploadRequest(r.Context(), params, r, teeReader)
if err != nil {
fail.Request(w, r, fmt.Errorf("dependency proxy: failed to create request: %w", err))
}
- saveFileRequest.Header = r.Header.Clone()
// forward headers from dependencyResponse to rails and client
for key, values := range dependencyResponse.Header {
@@ -100,17 +112,56 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin
}
}
-func (p *Injector) fetchUrl(ctx context.Context, sendData string) (*http.Response, error) {
+func (p *Injector) fetchUrl(ctx context.Context, params *entryParams) (*http.Response, error) {
+ r, err := http.NewRequestWithContext(ctx, "GET", params.Url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("dependency proxy: failed to fetch dependency: %w", err)
+ }
+ r.Header = params.Headers
+
+ return httpClient.Do(r)
+}
+
+func (p *Injector) newUploadRequest(ctx context.Context, params *entryParams, originalRequest *http.Request, body io.Reader) (*http.Request, error) {
+ method := p.uploadMethodFrom(params)
+ uploadUrl := p.uploadUrlFrom(params, originalRequest)
+ request, err := http.NewRequestWithContext(ctx, method, uploadUrl, body)
+ if err != nil {
+ return nil, err
+ }
+
+ request.Header = originalRequest.Header.Clone()
+
+ for key, values := range params.UploadConfig.Headers {
+ request.Header.Del(key)
+ for _, value := range values {
+ request.Header.Add(key, value)
+ }
+ }
+
+ return request, nil
+}
+
+func (p *Injector) unpackParams(sendData string) (*entryParams, error) {
var params entryParams
if err := p.Unpack(&params, sendData); err != nil {
return nil, fmt.Errorf("dependency proxy: unpack sendData: %v", err)
}
- r, err := http.NewRequestWithContext(ctx, "GET", params.Url, nil)
- if err != nil {
- return nil, fmt.Errorf("dependency proxy: failed to fetch dependency: %v", err)
+ return &params, nil
+}
+
+func (p *Injector) uploadMethodFrom(params *entryParams) string {
+ if params.UploadConfig.Method != "" {
+ return params.UploadConfig.Method
}
- r.Header = params.Header
+ return http.MethodPost
+}
- return httpClient.Do(r)
+func (p *Injector) uploadUrlFrom(params *entryParams, originalRequest *http.Request) string {
+ if params.UploadConfig.Url != "" {
+ return params.UploadConfig.Url
+ }
+
+ return originalRequest.URL.String() + "/upload"
}
diff --git a/workhorse/internal/dependencyproxy/dependencyproxy_test.go b/workhorse/internal/dependencyproxy/dependencyproxy_test.go
index d893ddc500f..bee74ce0a9e 100644
--- a/workhorse/internal/dependencyproxy/dependencyproxy_test.go
+++ b/workhorse/internal/dependencyproxy/dependencyproxy_test.go
@@ -2,6 +2,7 @@ package dependencyproxy
import (
"encoding/base64"
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -149,6 +150,158 @@ func TestSuccessfullRequest(t *testing.T) {
require.Equal(t, dockerContentDigest, response.Header().Get("Docker-Content-Digest"))
}
+func TestValidUploadConfiguration(t *testing.T) {
+ content := []byte("content")
+ contentLength := strconv.Itoa(len(content))
+ contentType := "text/plain"
+ testHeader := "test-received-url"
+ originResourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set(testHeader, r.URL.Path)
+ w.Header().Set("Content-Length", contentLength)
+ w.Header().Set("Content-Type", contentType)
+ w.Write(content)
+ }))
+
+ testCases := []struct {
+ desc string
+ uploadConfig *uploadConfig
+ expectedConfig uploadConfig
+ }{
+ {
+ desc: "with the default values",
+ expectedConfig: uploadConfig{
+ Method: http.MethodPost,
+ Url: "/target/upload",
+ },
+ }, {
+ desc: "with overriden method",
+ uploadConfig: &uploadConfig{
+ Method: http.MethodPut,
+ },
+ expectedConfig: uploadConfig{
+ Method: http.MethodPut,
+ Url: "/target/upload",
+ },
+ }, {
+ desc: "with overriden url",
+ uploadConfig: &uploadConfig{
+ Url: "http://test.org/overriden/upload",
+ },
+ expectedConfig: uploadConfig{
+ Method: http.MethodPost,
+ Url: "http://test.org/overriden/upload",
+ },
+ }, {
+ desc: "with overriden headers",
+ uploadConfig: &uploadConfig{
+ Headers: map[string][]string{"Private-Token": {"123456789"}},
+ },
+ expectedConfig: uploadConfig{
+ Headers: map[string][]string{"Private-Token": {"123456789"}},
+ Method: http.MethodPost,
+ Url: "/target/upload",
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ uploadHandler := &fakeUploadHandler{
+ handler: func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, tc.expectedConfig.Url, r.URL.String())
+ require.Equal(t, tc.expectedConfig.Method, r.Method)
+
+ if tc.expectedConfig.Headers != nil {
+ for k, v := range tc.expectedConfig.Headers {
+ require.Equal(t, v, r.Header[k])
+ }
+ }
+
+ w.WriteHeader(200)
+ },
+ }
+
+ injector := NewInjector()
+ injector.SetUploadHandler(uploadHandler)
+
+ sendData := map[string]interface{}{
+ "Token": "token",
+ "Url": originResourceServer.URL + `/remote/file`,
+ }
+
+ if tc.uploadConfig != nil {
+ sendData["UploadConfig"] = tc.uploadConfig
+ }
+
+ sendDataJsonString, err := json.Marshal(sendData)
+ require.NoError(t, err)
+
+ response := makeRequest(injector, string(sendDataJsonString))
+
+ //checking the response
+ require.Equal(t, 200, response.Code)
+ require.Equal(t, string(content), response.Body.String())
+ // checking remote file request
+ require.Equal(t, "/remote/file", response.Header().Get(testHeader))
+ })
+ }
+}
+
+func TestInvalidUploadConfiguration(t *testing.T) {
+ baseSendData := map[string]interface{}{
+ "Token": "token",
+ "Url": "http://remote.dev/remote/file",
+ }
+ testCases := []struct {
+ desc string
+ sendData map[string]interface{}
+ }{
+ {
+ desc: "with an invalid overriden method",
+ sendData: mergeMap(baseSendData, map[string]interface{}{
+ "UploadConfig": map[string]string{
+ "Method": "TEAPOT",
+ },
+ }),
+ }, {
+ desc: "with an invalid url",
+ sendData: mergeMap(baseSendData, map[string]interface{}{
+ "UploadConfig": map[string]string{
+ "Url": "invalid_url",
+ },
+ }),
+ }, {
+ desc: "with an invalid headers",
+ sendData: mergeMap(baseSendData, map[string]interface{}{
+ "UploadConfig": map[string]interface{}{
+ "Headers": map[string]string{
+ "Private-Token": "not_an_array",
+ },
+ },
+ }),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.desc, func(t *testing.T) {
+ sendDataJsonString, err := json.Marshal(tc.sendData)
+ require.NoError(t, err)
+
+ response := makeRequest(NewInjector(), string(sendDataJsonString))
+
+ require.Equal(t, 500, response.Code)
+ require.Equal(t, "Internal Server Error\n", response.Body.String())
+ })
+ }
+}
+
+func mergeMap(from map[string]interface{}, into map[string]interface{}) map[string]interface{} {
+ for k, v := range from {
+ into[k] = v
+ }
+ return into
+}
+
func TestIncorrectSendData(t *testing.T) {
response := makeRequest(NewInjector(), "")