diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-13 00:10:52 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-13 00:10:52 +0300 |
commit | a60e53c7671c299432f0c255ffaf0e0c9fa9eeab (patch) | |
tree | 9682f6acc0c40bd80beb79b9feec645f6252e8e0 | |
parent | 753eb533e509464184ad267fb894d2c08d0d1ba6 (diff) |
Add latest changes from gitlab-org/gitlab@master
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" @@ -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&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&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&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://gitlab.com/foo/foo.bar', ascii_only: true, schemes: schemes)).to be true + expect(described_class.blocked_url?('https://gitlab.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(¶ms, 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 ¶ms, 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(), "") |