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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-14 21:07:46 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-14 21:07:46 +0300
commitfbf2955cfc9ffc319d57960a0b0df1ee1b5fd05f (patch)
tree6964ec0aaac3d432a4795878e87d78566f7bf719
parent739467f1fa4d5d4042b47ff6637a567d1ad6a4a4 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo/rspec/timecop_travel.yml12
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_actions.vue21
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters_main_view.vue24
-rw-r--r--app/assets/javascripts/clusters_list/constants.js25
-rw-r--r--app/assets/javascripts/clusters_list/index.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js6
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js7
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_deserializer.js12
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue2
-rw-r--r--app/controllers/admin/application_settings_controller.rb2
-rw-r--r--app/controllers/admin/clusters_controller.rb1
-rw-r--r--app/controllers/groups/clusters_controller.rb1
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb2
-rw-r--r--app/graphql/mutations/ci/runners_registration_token/reset.rb13
-rw-r--r--app/helpers/application_settings_helper.rb5
-rw-r--r--app/helpers/clusters_helper.rb3
-rw-r--r--app/models/concerns/bulk_member_access_load.rb52
-rw-r--r--app/models/group.rb4
-rw-r--r--app/models/project.rb9
-rw-r--r--app/models/project_authorization.rb2
-rw-r--r--app/models/project_team.rb4
-rw-r--r--app/models/projects/triggered_hooks.rb25
-rw-r--r--app/models/user.rb8
-rw-r--r--app/policies/application_setting_policy.rb5
-rw-r--r--app/policies/global_policy.rb1
-rw-r--r--app/services/ci/runners/reset_registration_token_service.rb31
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml19
-rw-r--r--app/views/projects/_new_project_fields.html.haml3
-rw-r--r--db/post_migrate/20210812013042_remove_duplicate_project_authorizations.rb111
-rw-r--r--db/schema_migrations/202108120130421
-rw-r--r--db/structure.sql4
-rw-r--r--doc/api/api_resources.md1
-rw-r--r--doc/api/jobs.md2
-rw-r--r--doc/api/linked_epics.md90
-rw-r--r--doc/ci/yaml/index.md2
-rw-r--r--doc/development/secure_coding_guidelines.md34
-rw-r--r--doc/user/clusters/agent/ci_cd_tunnel.md32
-rw-r--r--lib/api/ci/runners.rb4
-rw-r--r--lib/gitlab/analytics/cycle_analytics/request_params.rb4
-rw-r--r--lib/gitlab/safe_request_loader.rb55
-rw-r--r--lib/sidebars/groups/menus/kubernetes_menu.rb5
-rw-r--r--lib/tasks/gitlab/tw/codeowners.rake2
-rw-r--r--locale/gitlab.pot9
-rw-r--r--spec/controllers/admin/clusters_controller_spec.rb2
-rw-r--r--spec/controllers/groups/clusters_controller_spec.rb2
-rw-r--r--spec/factories/project_hooks.rb4
-rw-r--r--spec/features/users/terms_spec.rb2
-rw-r--r--spec/frontend/clusters_list/components/clusters_actions_spec.js144
-rw-r--r--spec/frontend/clusters_list/components/clusters_main_view_spec.js155
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js21
-rw-r--r--spec/frontend/content_editor/services/markdown_deserializer_spec.js31
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js4
-rw-r--r--spec/helpers/application_settings_helper_spec.rb21
-rw-r--r--spec/helpers/clusters_helper_spec.rb22
-rw-r--r--spec/lib/feature_spec.rb4
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb4
-rw-r--r--spec/lib/gitlab/safe_request_loader_spec.rb180
-rw-r--r--spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb10
-rw-r--r--spec/migrations/20210812013042_remove_duplicate_project_authorizations_spec.rb62
-rw-r--r--spec/models/broadcast_message_spec.rb6
-rw-r--r--spec/models/concerns/issuable_spec.rb4
-rw-r--r--spec/models/project_authorization_spec.rb50
-rw-r--r--spec/models/projects/triggered_hooks_spec.rb48
-rw-r--r--spec/policies/application_setting_policy_spec.rb40
-rw-r--r--spec/policies/global_policy_spec.rb30
-rw-r--r--spec/requests/api/ci/runner/jobs_trace_spec.rb8
-rw-r--r--spec/requests/api/issues/put_projects_issues_spec.rb16
-rw-r--r--spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb13
-rw-r--r--spec/services/ci/runners/reset_registration_token_service_spec.rb76
-rw-r--r--spec/services/users/refresh_authorized_projects_service_spec.rb25
-rw-r--r--spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb4
-rw-r--r--spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb15
74 files changed, 1287 insertions, 383 deletions
diff --git a/.rubocop_todo/rspec/timecop_travel.yml b/.rubocop_todo/rspec/timecop_travel.yml
index 29044236deb..32133f6e55c 100644
--- a/.rubocop_todo/rspec/timecop_travel.yml
+++ b/.rubocop_todo/rspec/timecop_travel.yml
@@ -7,15 +7,5 @@ RSpec/TimecopTravel:
- ee/spec/lib/gitlab/geo/log_cursor/daemon_spec.rb
- ee/spec/models/broadcast_message_spec.rb
- ee/spec/models/burndown_spec.rb
- - qa/spec/support/repeater_spec.rb
- - spec/features/users/terms_spec.rb
- - spec/lib/feature_spec.rb
- - spec/models/broadcast_message_spec.rb
- - spec/models/concerns/issuable_spec.rb
- - spec/requests/api/ci/runner/jobs_trace_spec.rb
- - spec/requests/api/issues/put_projects_issues_spec.rb
- - spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
- - spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
- - spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
- spec/workers/concerns/reenqueuer_spec.rb
- - spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb
+ - qa/spec/support/repeater_spec.rb
diff --git a/app/assets/javascripts/clusters_list/components/clusters_actions.vue b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
index 81aa867ab00..ccb973f1eb8 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_actions.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_actions.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlModalDirective,
@@ -14,6 +15,7 @@ export default {
i18n: CLUSTERS_ACTIONS,
INSTALL_AGENT_MODAL_ID,
components: {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
@@ -23,7 +25,13 @@ export default {
GlModalDirective,
GlTooltip: GlTooltipDirective,
},
- inject: ['newClusterPath', 'addClusterPath', 'canAddCluster', 'displayClusterAgents'],
+ inject: [
+ 'newClusterPath',
+ 'addClusterPath',
+ 'canAddCluster',
+ 'displayClusterAgents',
+ 'certificateBasedClustersEnabled',
+ ],
computed: {
tooltip() {
const { connectWithAgent, connectExistingCluster, dropdownDisabledHint } = this.$options.i18n;
@@ -46,6 +54,7 @@ export default {
<template>
<div class="nav-controls gl-ml-auto">
<gl-dropdown
+ v-if="certificateBasedClustersEnabled"
ref="dropdown"
v-gl-modal-directive="shouldTriggerModal && $options.INSTALL_AGENT_MODAL_ID"
v-gl-tooltip="tooltip"
@@ -75,5 +84,15 @@ export default {
{{ $options.i18n.connectExistingCluster }}
</gl-dropdown-item>
</gl-dropdown>
+ <gl-button
+ v-else
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ v-gl-tooltip="tooltip"
+ :disabled="!canAddCluster"
+ category="primary"
+ variant="confirm"
+ >
+ {{ $options.i18n.connectWithAgent }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
index eaaff74286a..aab6d3dc1f0 100644
--- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue
@@ -9,6 +9,7 @@ import {
AGENT,
EVENT_LABEL_TABS,
EVENT_ACTIONS_CHANGE,
+ AGENT_TAB,
} from '../constants';
import Agents from './agents.vue';
import InstallAgentModal from './install_agent_modal.vue';
@@ -28,9 +29,8 @@ export default {
Agents,
InstallAgentModal,
},
- CLUSTERS_TABS,
mixins: [trackingMixin],
- inject: ['displayClusterAgents'],
+ inject: ['displayClusterAgents', 'certificateBasedClustersEnabled'],
props: {
defaultBranchName: {
default: '.noBranch',
@@ -45,21 +45,27 @@ export default {
};
},
computed: {
- clusterTabs() {
- return this.displayClusterAgents ? CLUSTERS_TABS : [CERTIFICATE_TAB];
+ availableTabs() {
+ const clusterTabs = this.displayClusterAgents ? CLUSTERS_TABS : [CERTIFICATE_TAB];
+ return this.certificateBasedClustersEnabled ? clusterTabs : [AGENT_TAB];
},
},
watch: {
- selectedTabIndex(val) {
- this.onTabChange(val);
+ selectedTabIndex: {
+ handler(val) {
+ this.onTabChange(val);
+ },
+ immediate: true,
},
},
methods: {
setSelectedTab(tabName) {
- this.selectedTabIndex = this.clusterTabs.findIndex((tab) => tab.queryParamValue === tabName);
+ this.selectedTabIndex = this.availableTabs.findIndex(
+ (tab) => tab.queryParamValue === tabName,
+ );
},
onTabChange(tab) {
- const tabName = this.clusterTabs[tab].queryParamValue;
+ const tabName = this.availableTabs[tab].queryParamValue;
this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST;
this.track(EVENT_ACTIONS_CHANGE, { property: tabName });
@@ -76,7 +82,7 @@ export default {
lazy
>
<gl-tab
- v-for="(tab, idx) in clusterTabs"
+ v-for="(tab, idx) in availableTabs"
:key="idx"
:title="tab.title"
:query-param-value="tab.queryParamValue"
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index ba7eedcf6bf..046a3f7e4fc 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -232,25 +232,24 @@ export const CERTIFICATE_BASED_CARD_INFO = {
export const MAX_CLUSTERS_LIST = 6;
+export const ALL_TAB = {
+ title: s__('ClusterAgents|All'),
+ component: 'ClustersViewAll',
+ queryParamValue: 'all',
+};
+
+export const AGENT_TAB = {
+ title: s__('ClusterAgents|Agent'),
+ component: 'agents',
+ queryParamValue: 'agent',
+};
export const CERTIFICATE_TAB = {
title: s__('ClusterAgents|Certificate'),
component: 'clusters',
queryParamValue: 'certificate_based',
};
-export const CLUSTERS_TABS = [
- {
- title: s__('ClusterAgents|All'),
- component: 'ClustersViewAll',
- queryParamValue: 'all',
- },
- {
- title: s__('ClusterAgents|Agent'),
- component: 'agents',
- queryParamValue: 'agent',
- },
- CERTIFICATE_TAB,
-];
+export const CLUSTERS_TABS = [ALL_TAB, AGENT_TAB, CERTIFICATE_TAB];
export const CLUSTERS_ACTIONS = {
actionsButton: s__('ClusterAgents|Actions'),
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index da1774b3135..27eebc9d891 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -31,6 +31,7 @@ export default () => {
canAdminCluster,
gitlabVersion,
displayClusterAgents,
+ certificateBasedClustersEnabled,
} = el.dataset;
return new Vue({
@@ -48,6 +49,7 @@ export default () => {
canAdminCluster: parseBoolean(canAdminCluster),
gitlabVersion,
displayClusterAgents: parseBoolean(displayClusterAgents),
+ certificateBasedClustersEnabled: parseBoolean(certificateBasedClustersEnabled),
},
store: createStore(el.dataset),
render(createElement) {
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 31774e0ec51..c349aa42a62 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -34,15 +34,15 @@ export default Extension.create({
deserializer
.deserialize({ schema: editor.schema, content: markdown })
- .then((doc) => {
- if (!doc) {
+ .then(({ document }) => {
+ if (!document) {
return;
}
const { state, view } = editor;
const { tr, selection } = state;
- tr.replaceWith(selection.from - 1, selection.to, doc.content);
+ tr.replaceWith(selection.from - 1, selection.to, document.content);
view.dispatch(tr);
eventHub.$emit(LOADING_SUCCESS_EVENT);
})
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index e0995a5974c..c5638da2daf 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -40,13 +40,14 @@ export class ContentEditor {
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
- const newDoc = await deserializer.deserialize({
+ const { document } = await deserializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
- if (newDoc) {
+
+ if (document) {
tr.setSelection(selection)
- .replaceSelectionWith(newDoc, false)
+ .replaceSelectionWith(document, false)
.setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
diff --git a/app/assets/javascripts/content_editor/services/markdown_deserializer.js b/app/assets/javascripts/content_editor/services/markdown_deserializer.js
index ccffcd4cee8..cd4863d8eac 100644
--- a/app/assets/javascripts/content_editor/services/markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_deserializer.js
@@ -4,16 +4,22 @@ export default ({ render }) => {
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
+ *
+ * @param {Object} options — The schema and content for deserialization
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content An arbitrary markdown string
- * @returns A ProseMirror JSONDocument
+ *
+ * @returns An object with the following properties:
+ * - document: A ProseMirror document object generated from the deserialized Markdown
+ * - dom: The Markdown Deserializer renders Markdown as HTML to generate the ProseMirror
+ * document. The dom property contains the HTML generated from the Markdown Source.
*/
return {
deserialize: async ({ schema, content }) => {
const html = await render(content);
- if (!html) return null;
+ if (!html) return {};
const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
@@ -21,7 +27,7 @@ export default ({ render }) => {
// append original source as a comment that nodes can access
body.append(document.createComment(content));
- return ProseMirrorDOMParser.fromSchema(schema).parse(body);
+ return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body), dom: body };
},
};
};
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index 3bb1f58573c..c72d488f844 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -43,7 +43,7 @@ export default {
<template>
<div
- class="log-line collapsible-line d-flex justify-content-between ws-normal"
+ class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start"
role="button"
@click="handleOnClick"
>
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 1d0930ba73c..f9df0307a01 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -71,7 +71,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def reset_registration_token
- @application_setting.reset_runners_registration_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(@application_setting, current_user).execute
flash[:notice] = _('New runners registration token has been generated!')
redirect_to admin_runners_path
diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb
index 9a642e53d86..052c8821588 100644
--- a/app/controllers/admin/clusters_controller.rb
+++ b/app/controllers/admin/clusters_controller.rb
@@ -2,6 +2,7 @@
class Admin::ClustersController < Clusters::ClustersController
include EnforcesAdminAuthentication
+ before_action :ensure_feature_enabled!
layout 'admin'
diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb
index 666a96d6fc0..2fe9faa252f 100644
--- a/app/controllers/groups/clusters_controller.rb
+++ b/app/controllers/groups/clusters_controller.rb
@@ -3,6 +3,7 @@
class Groups::ClustersController < Clusters::ClustersController
include ControllerWithCrossProjectAccessCheck
+ before_action :ensure_feature_enabled!
prepend_before_action :group
requires_cross_project_access
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index a290ef9b5e7..9b9e3f7b0bc 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -36,7 +36,7 @@ module Groups
end
def reset_registration_token
- @group.reset_runners_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(@group, current_user).execute
flash[:notice] = _('GroupSettings|New runners registration token has been generated!')
redirect_to group_settings_ci_cd_path
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index dd2fb57f7ac..3f4d26bb6ec 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -64,7 +64,7 @@ module Projects
end
def reset_registration_token
- @project.reset_runners_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(@project, current_user).execute
flash[:toast] = _("New runners registration token has been generated!")
redirect_to namespace_project_settings_ci_cd_path
diff --git a/app/graphql/mutations/ci/runners_registration_token/reset.rb b/app/graphql/mutations/ci/runners_registration_token/reset.rb
index 7976e8fb70d..29ef7aa2e81 100644
--- a/app/graphql/mutations/ci/runners_registration_token/reset.rb
+++ b/app/graphql/mutations/ci/runners_registration_token/reset.rb
@@ -45,20 +45,19 @@ module Mutations
def reset_token(type:, **args)
id = args[:id]
+ scope = nil
case type
when 'instance_type'
raise Gitlab::Graphql::Errors::ArgumentError, "id must not be specified for '#{type}' scope" if id.present?
- authorize!(:global)
-
- ApplicationSetting.current.reset_runners_registration_token!
- ApplicationSetting.current_without_cache.runners_registration_token
+ scope = ApplicationSetting.current
+ authorize!(scope)
when 'group_type', 'project_type'
- project_or_group = authorized_find!(type: type, id: id)
- project_or_group.reset_runners_token!
- project_or_group.runners_token
+ scope = authorized_find!(type: type, id: id)
end
+
+ ::Ci::Runners::ResetRegistrationTokenService.new(scope, current_user).execute if scope
end
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index d9a1731e820..a9c13b2fdeb 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -465,7 +465,10 @@ module ApplicationSettingsHelper
end
def instance_clusters_enabled?
- can?(current_user, :read_cluster, Clusters::Instance.new)
+ clusterable = Clusters::Instance.new
+
+ Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops) &&
+ can?(current_user, :read_cluster, clusterable)
end
def omnibus_protected_paths_throttle?
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index ae890685dc6..62d93d75b11 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -31,7 +31,8 @@ module ClustersHelper
add_cluster_path: clusterable.new_path(tab: 'add'),
can_add_cluster: clusterable.can_add_cluster?.to_s,
can_admin_cluster: clusterable.can_admin_cluster?.to_s,
- display_cluster_agents: display_cluster_agents?(clusterable).to_s
+ display_cluster_agents: display_cluster_agents?(clusterable).to_s,
+ certificate_based_clusters_enabled: Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops).to_s
}
end
diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb
index 927d6ccb28f..efc65e55e40 100644
--- a/app/models/concerns/bulk_member_access_load.rb
+++ b/app/models/concerns/bulk_member_access_load.rb
@@ -1,61 +1,19 @@
# frozen_string_literal: true
-# Returns and caches in thread max member access for a resource
-#
module BulkMemberAccessLoad
extend ActiveSupport::Concern
included do
- # Determine the maximum access level for a group of resources in bulk.
- #
- # Returns a Hash mapping resource ID -> maximum access level.
- def max_member_access_for_resource_ids(resource_klass, resource_ids, &block)
- raise 'Block is mandatory' unless block_given?
-
- memoization_index = self.id
- memoization_class = self.class
-
- resource_ids = resource_ids.uniq
- memo_id = "#{memoization_class}:#{memoization_index}"
- access = load_access_hash(resource_klass, memo_id)
-
- # Look up only the IDs we need
- resource_ids -= access.keys
-
- return access if resource_ids.empty?
-
- resource_access = yield(resource_ids)
-
- access.merge!(resource_access)
-
- missing_resource_ids = resource_ids - resource_access.keys
-
- missing_resource_ids.each do |resource_id|
- access[resource_id] = Gitlab::Access::NO_ACCESS
- end
-
- access
- end
-
def merge_value_to_request_store(resource_klass, resource_id, value)
- max_member_access_for_resource_ids(resource_klass, [resource_id]) do
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass),
+ resource_ids: [resource_id],
+ default_value: Gitlab::Access::NO_ACCESS) do
{ resource_id => value }
end
end
- private
-
- def max_member_access_for_resource_key(klass, memoization_index)
- "max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}"
- end
-
- def load_access_hash(resource_klass, memo_id)
- return {} unless Gitlab::SafeRequestStore.active?
-
- key = max_member_access_for_resource_key(resource_klass, memo_id)
- Gitlab::SafeRequestStore[key] ||= {}
-
- Gitlab::SafeRequestStore[key]
+ def max_member_access_for_resource_key(klass)
+ "max_member_access_for_#{klass.name.underscore.pluralize}:#{self.class}:#{self.id}"
end
end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index e9fb4c36ba6..14d088dd38b 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -816,7 +816,9 @@ class Group < Namespace
private
def max_member_access(user_ids)
- max_member_access_for_resource_ids(User, user_ids) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level)
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 6457cce9364..e55395b32e7 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -1567,14 +1567,17 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def execute_hooks(data, hooks_scope = :push_hooks)
run_after_commit_or_now do
- hooks.hooks_for(hooks_scope).select_active(hooks_scope, data).each do |hook|
- hook.async_execute(data, hooks_scope.to_s)
- end
+ triggered_hooks(hooks_scope, data).execute
SystemHooksService.new.execute_hooks(data, hooks_scope)
end
end
# rubocop: enable CodeReuse/ServiceClass
+ def triggered_hooks(hooks_scope, data)
+ triggered = ::Projects::TriggeredHooks.new(hooks_scope, data)
+ triggered.add_hooks(hooks)
+ end
+
def execute_integrations(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
run_after_commit_or_now do
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index c76332b21cd..5c6fdec16ca 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -9,7 +9,7 @@ class ProjectAuthorization < ApplicationRecord
validates :project, presence: true
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
- validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
+ validates :user, uniqueness: { scope: :project }, presence: true
def self.select_from_union(relations)
from_union(relations)
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index ee5ecc2dd3c..d5e0d112aeb 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -179,7 +179,9 @@ class ProjectTeam
#
# Returns a Hash mapping user ID -> maximum access level.
def max_member_access_for_user_ids(user_ids)
- project.max_member_access_for_resource_ids(User, user_ids) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: project.max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
project.project_authorizations
.where(user: user_ids)
.group(:user_id)
diff --git a/app/models/projects/triggered_hooks.rb b/app/models/projects/triggered_hooks.rb
new file mode 100644
index 00000000000..e3aa3d106b7
--- /dev/null
+++ b/app/models/projects/triggered_hooks.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Projects
+ class TriggeredHooks
+ def initialize(scope, data)
+ @scope = scope
+ @data = data
+ @relations = []
+ end
+
+ def add_hooks(relation)
+ @relations << relation
+ self
+ end
+
+ def execute
+ # Assumes that the relations implement TriggerableHooks
+ @relations.each do |hooks|
+ hooks.hooks_for(@scope).select_active(@scope, @data).each do |hook|
+ hook.async_execute(@data, @scope.to_s)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index fa58455ad35..4c375fe5230 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1862,7 +1862,9 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_project_ids(project_ids)
- max_member_access_for_resource_ids(Project, project_ids) do |project_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Project),
+ resource_ids: project_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |project_ids|
project_authorizations.where(project: project_ids)
.group(:project_id)
.maximum(:access_level)
@@ -1877,7 +1879,9 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_group_ids(group_ids)
- max_member_access_for_resource_ids(Group, group_ids) do |group_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Group),
+ resource_ids: group_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |group_ids|
group_members.where(source: group_ids).group(:source_id).maximum(:access_level)
end
end
diff --git a/app/policies/application_setting_policy.rb b/app/policies/application_setting_policy.rb
index 114c71fd99d..6d0b5f36fa4 100644
--- a/app/policies/application_setting_policy.rb
+++ b/app/policies/application_setting_policy.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
class ApplicationSettingPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass
- rule { admin }.enable :read_application_setting
+ rule { admin }.policy do
+ enable :read_application_setting
+ enable :update_runners_registration_token
+ end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 2a2ddf29899..fa7b117f3cd 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -115,7 +115,6 @@ class GlobalPolicy < BasePolicy
enable :approve_user
enable :reject_user
enable :read_usage_trends_measurement
- enable :update_runners_registration_token
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/services/ci/runners/reset_registration_token_service.rb b/app/services/ci/runners/reset_registration_token_service.rb
new file mode 100644
index 00000000000..bbe49c04644
--- /dev/null
+++ b/app/services/ci/runners/reset_registration_token_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ module Runners
+ class ResetRegistrationTokenService
+ # @param [ApplicationSetting, Project, Group] scope: the scope of the reset operation
+ # @param [User] user: the user performing the operation
+ def initialize(scope, user)
+ @scope = scope
+ @user = user
+ end
+
+ def execute
+ return unless @user.present? && @user.can?(:update_runners_registration_token, scope)
+
+ case scope
+ when ::ApplicationSetting
+ scope.reset_runners_registration_token!
+ ApplicationSetting.current_without_cache.runners_registration_token
+ when ::Group, ::Project
+ scope.reset_runners_token!
+ scope.runners_token
+ end
+ end
+
+ private
+
+ attr_reader :scope, :user
+ end
+ end
+end
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 9d249931a34..3a4632affdc 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,11 +1,10 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } }
- .gl-alert-container
- %button.js-close.btn.gl-dismiss-btn.btn-default.btn-sm.gl-button.btn-default-tertiary.btn-icon{ type: 'button', 'aria-label' => _('Dismiss') }
- = sprite_icon('close', size: 16, css_class: 'gl-icon')
- = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
- .gl-alert-content
- %h4.gl-alert-title= s_('ClusterIntegration|Did you know?')
- %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
- %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
- = s_("ClusterIntegration|Apply for credit")
+= render 'shared/global_alert',
+ title: s_('ClusterIntegration|Did you know?'),
+ alert_class: 'gcp-signup-offer',
+ alert_data: { feature_id: Users::CalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: callouts_path } do
+ .gl-alert-body
+ = s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
+ .gl-alert-actions
+ %a.gl-button.btn-confirm.text-decoration-none{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' }
+ = s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index b8a15ab698c..2d0a4ae8605 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -46,7 +46,8 @@
= s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
= f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { qa_selector: 'project_description', track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" }
-.js-deployment-target-select
+- unless Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
+ .js-deployment-target-select
= f.label :visibility_level, class: 'label-bold' do
= s_('ProjectsNew|Visibility Level')
diff --git a/db/post_migrate/20210812013042_remove_duplicate_project_authorizations.rb b/db/post_migrate/20210812013042_remove_duplicate_project_authorizations.rb
new file mode 100644
index 00000000000..6fdc30d09c6
--- /dev/null
+++ b/db/post_migrate/20210812013042_remove_duplicate_project_authorizations.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+class RemoveDuplicateProjectAuthorizations < ActiveRecord::Migration[6.1]
+ include Gitlab::Database::MigrationHelpers
+
+ BATCH_SIZE = 10_000
+ OLD_INDEX_NAME = 'index_project_authorizations_on_project_id_user_id'
+ INDEX_NAME = 'index_unique_project_authorizations_on_project_id_user_id'
+
+ class ProjectAuthorization < ActiveRecord::Base
+ self.table_name = 'project_authorizations'
+ end
+
+ disable_ddl_transaction!
+
+ def up
+ batch do |first_record, last_record|
+ break if first_record.blank?
+
+ # construct a range query where we filter records between the first and last records
+ rows = ActiveRecord::Base.connection.execute <<~SQL
+ SELECT user_id, project_id
+ FROM project_authorizations
+ WHERE
+ #{start_condition(first_record)}
+ #{end_condition(last_record)}
+ GROUP BY user_id, project_id
+ HAVING COUNT(*) > 1
+ SQL
+
+ rows.each do |row|
+ deduplicate_item(row['project_id'], row['user_id'])
+ end
+ end
+
+ add_concurrent_index :project_authorizations, [:project_id, :user_id], unique: true, name: INDEX_NAME
+ remove_concurrent_index_by_name :project_authorizations, OLD_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index(:project_authorizations, [:project_id, :user_id], name: OLD_INDEX_NAME)
+ remove_concurrent_index_by_name(:project_authorizations, INDEX_NAME)
+ end
+
+ private
+
+ def start_condition(record)
+ "(user_id, project_id) >= (#{Integer(record.user_id)}, #{Integer(record.project_id)})"
+ end
+
+ def end_condition(record)
+ return "" unless record
+
+ "AND (user_id, project_id) <= (#{Integer(record.user_id)}, #{Integer(record.project_id)})"
+ end
+
+ def batch(&block)
+ order = Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'user_id',
+ order_expression: ProjectAuthorization.arel_table[:user_id].asc,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'project_id',
+ order_expression: ProjectAuthorization.arel_table[:project_id].asc,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'access_level',
+ order_expression: ProjectAuthorization.arel_table[:access_level].asc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+
+ scope = ProjectAuthorization.order(order)
+ cursor = {}
+ loop do
+ current_scope = scope.dup
+
+ relation = order.apply_cursor_conditions(current_scope, cursor)
+ first_record = relation.take
+ last_record = relation.offset(BATCH_SIZE).take
+
+ yield first_record, last_record
+
+ break if last_record.blank?
+
+ cursor = order.cursor_attributes_for_node(last_record)
+ end
+ end
+
+ def deduplicate_item(project_id, user_id)
+ auth_records = ProjectAuthorization.where(project_id: project_id, user_id: user_id).order(access_level: :desc).to_a
+
+ ActiveRecord::Base.transaction do
+ # Keep the highest access level and destroy the rest.
+ auth_records[1..].each do |record|
+ ProjectAuthorization
+ .where(
+ project_id: record.project_id,
+ user_id: record.user_id,
+ access_level: record.access_level
+ ).delete_all
+ end
+ end
+ end
+end
diff --git a/db/schema_migrations/20210812013042 b/db/schema_migrations/20210812013042
new file mode 100644
index 00000000000..fee1a2b268a
--- /dev/null
+++ b/db/schema_migrations/20210812013042
@@ -0,0 +1 @@
+0af6e6e56967cef9d1160dbfd95456428337843d893307c69505e1a2d3c2074a \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 8cb77345afb..2bda19b1cdc 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -28522,8 +28522,6 @@ CREATE UNIQUE INDEX index_project_aliases_on_name ON project_aliases USING btree
CREATE INDEX index_project_aliases_on_project_id ON project_aliases USING btree (project_id);
-CREATE INDEX index_project_authorizations_on_project_id_user_id ON project_authorizations USING btree (project_id, user_id);
-
CREATE UNIQUE INDEX index_project_auto_devops_on_project_id ON project_auto_devops USING btree (project_id);
CREATE UNIQUE INDEX index_project_ci_cd_settings_on_project_id ON project_ci_cd_settings USING btree (project_id);
@@ -29160,6 +29158,8 @@ CREATE UNIQUE INDEX index_unique_ci_runner_projects_on_runner_id_and_project_id
CREATE UNIQUE INDEX index_unique_issue_metrics_issue_id ON issue_metrics USING btree (issue_id);
+CREATE UNIQUE INDEX index_unique_project_authorizations_on_project_id_user_id ON project_authorizations USING btree (project_id, user_id);
+
CREATE INDEX index_unit_test_failures_failed_at ON ci_unit_test_failures USING btree (failed_at DESC);
CREATE UNIQUE INDEX index_unit_test_failures_unique_columns ON ci_unit_test_failures USING btree (unit_test_id, failed_at DESC, build_id);
diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md
index 3d54402ea0b..eabaa4217b5 100644
--- a/doc/api/api_resources.md
+++ b/doc/api/api_resources.md
@@ -118,6 +118,7 @@ The following API resources are available in the group context:
| [Invitations](invitations.md) | `/groups/:id/invitations` (also available for projects) |
| [Issues](issues.md) | `/groups/:id/issues` (also available for projects and standalone) |
| [Issues Statistics](issues_statistics.md) | `/groups/:id/issues_statistics` (also available for projects and standalone) |
+| [Linked epics](linked_epics.md) | `/groups/:id/epics/.../related_epics` |
| [Members](members.md) | `/groups/:id/members` (also available for projects) |
| [Merge requests](merge_requests.md) | `/groups/:id/merge_requests` (also available for projects and standalone) |
| [Notes](notes.md) (comments) | `/groups/:id/epics/.../notes` (also available for projects) |
diff --git a/doc/api/jobs.md b/doc/api/jobs.md
index 135161cfa06..85cdf7d892a 100644
--- a/doc/api/jobs.md
+++ b/doc/api/jobs.md
@@ -814,7 +814,7 @@ NOTE:
You can't delete archived jobs with the API, but you can
[delete job artifacts and logs from jobs completed before a specific date](../administration/job_artifacts.md#delete-job-artifacts-and-logs-from-jobs-completed-before-a-specific-date)
-## Play a job
+## Run a job
Triggers a manual action to start a job.
diff --git a/doc/api/linked_epics.md b/doc/api/linked_epics.md
new file mode 100644
index 00000000000..b5112d9c989
--- /dev/null
+++ b/doc/api/linked_epics.md
@@ -0,0 +1,90 @@
+---
+stage: Plan
+group: Product Planning
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+---
+
+# Linked epics API **(ULTIMATE)**
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/352493) in GitLab 14.9 [with a flag](../administration/feature_flags.md) named `related_epics_widget`. Disabled by default.
+
+FLAG:
+On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `related_epics_widget`. On GitLab.com, this feature is not available.
+
+If the Related Epics feature is not available in your GitLab plan, a `403` status code is returned.
+
+## List linked epics
+
+Get a list of a given epic's linked epics filtered according to the user authorizations.
+
+```plaintext
+GET /groups/:id/epics/:epic_iid/related_epics
+```
+
+Supported attributes:
+
+| Attribute | Type | Required | Description |
+| ---------- | -------------- | ---------------------- | ------------------------------------------------------------------------- |
+| `epic_iid` | integer | **{check-circle}** Yes | Internal ID of a group's epic |
+| `id` | integer/string | **{check-circle}** Yes | ID or [URL-encoded path of the group](index.md#namespaced-path-encoding). |
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/:id/epics/:epic_iid/related_epics"
+```
+
+Example response:
+
+```json
+[
+ {
+ "id":2,
+ "iid":2,
+ "color":"#1068bf",
+ "text_color":"#FFFFFF",
+ "group_id":2,
+ "parent_id":null,
+ "parent_iid":null,
+ "title":"My title 2",
+ "description":null,
+ "confidential":false,
+ "author":{
+ "id":3,
+ "username":"user3",
+ "name":"Sidney Jones4",
+ "state":"active",
+ "avatar_url":"https://www.gravatar.com/avatar/82797019f038ab535a84c6591e7bc936?s=80u0026d=identicon",
+ "web_url":"http://localhost/user3"
+ },
+ "start_date":null,
+ "end_date":null,
+ "due_date":null,
+ "state":"opened",
+ "web_url":"http://localhost/groups/group1/-/epics/2",
+ "references":{
+ "short":"u00262",
+ "relative":"u00262",
+ "full":"group1u00262"
+ },
+ "created_at":"2022-03-10T18:35:24.479Z",
+ "updated_at":"2022-03-10T18:35:24.479Z",
+ "closed_at":null,
+ "labels":[
+
+ ],
+ "upvotes":0,
+ "downvotes":0,
+ "_links":{
+ "self":"http://localhost/api/v4/groups/2/epics/2",
+ "epic_issues":"http://localhost/api/v4/groups/2/epics/2/issues",
+ "group":"http://localhost/api/v4/groups/2",
+ "parent":null
+ },
+ "related_epic_link_id":1,
+ "link_type":"relates_to",
+ "link_created_at":"2022-03-10T18:35:24.496+00:00",
+ "link_updated_at":"2022-03-10T18:35:24.496+00:00"
+ }
+]
+```
diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md
index e754e7081b9..85692d08c07 100644
--- a/doc/ci/yaml/index.md
+++ b/doc/ci/yaml/index.md
@@ -1863,7 +1863,7 @@ image:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207484) in GitLab 12.9.
-Use `inherit` to [control inheritance of globally-defined defaults and variables](../jobs/index.md#control-the-inheritance-of-default-keywords-and-global-variables).
+Use `inherit` to [control inheritance of default keywords and variables](../jobs/index.md#control-the-inheritance-of-default-keywords-and-global-variables).
#### `inherit:default`
diff --git a/doc/development/secure_coding_guidelines.md b/doc/development/secure_coding_guidelines.md
index bc876667f8d..10f6c22e54a 100644
--- a/doc/development/secure_coding_guidelines.md
+++ b/doc/development/secure_coding_guidelines.md
@@ -267,10 +267,13 @@ of these guidelines.
#### Feature-specific mitigations
-For situations in which an allowlist or GitLab:HTTP cannot be used, it will be necessary to implement mitigations directly in the feature. It is best to validate the destination IP addresses themselves, not just domain names, as DNS can be controlled by the attacker. Below are a list of mitigations that should be implemented.
-
There are many tricks to bypass common SSRF validations. If feature-specific mitigations are necessary, they should be reviewed by the AppSec team, or a developer who has worked on SSRF mitigations previously.
+For situations in which you can't use an allowlist or GitLab:HTTP, you must implement mitigations
+directly in the feature. It's best to validate the destination IP addresses themselves, not just
+domain names, as the attacker can control DNS. Below is a list of mitigations that you should
+implement.
+
- Block connections to all localhost addresses
- `127.0.0.1/8` (IPv4 - note the subnet mask)
- `::1` (IPv6)
@@ -286,6 +289,33 @@ There are many tricks to bypass common SSRF validations. If feature-specific mit
See [`url_blocker_spec.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/lib/gitlab/url_blocker_spec.rb) for examples of SSRF payloads. See [time of check to time of use bugs](#time-of-check-to-time-of-use-bugs) to learn more about DNS rebinding's class of bug.
+Don't rely on methods like `.start_with?` when validating a URL, or make assumptions about which
+part of a string maps to which part of a URL. Use the `URI` class to parse the string, and validate
+each component (scheme, host, port, path, and so on). Attackers can create valid URLs which look
+safe, but lead to malicious locations.
+
+```ruby
+user_supplied_url = "https://my-safe-site.com@my-evil-site.com" # Content before an @ in a URL is usually for basic authentication
+user_supplied_url.start_with?("https://my-safe-site.com") # Don't trust with start_with? for URLs!
+=> true
+URI.parse(user_supplied_url).host
+=> "my-evil-site.com"
+
+user_supplied_url = "https://my-safe-site.com-my-evil-site.com"
+user_supplied_url.start_with?("https://my-safe-site.com") # Don't trust with start_with? for URLs!
+=> true
+URI.parse(user_supplied_url).host
+=> "my-safe-site.com-my-evil-site.com"
+
+# Here's an example where we unsafely attempt to validate a host while allowing for
+# subdomains
+user_supplied_url = "https://my-evil-site-my-safe-site.com"
+user_supplied_host = URI.parse(user_supplied_url).host
+=> "my-evil-site-my-safe-site.com"
+user_supplied_host.end_with?("my-safe-site.com") # Don't trust with end_with?
+=> true
+```
+
## XSS guidelines
### Description
diff --git a/doc/user/clusters/agent/ci_cd_tunnel.md b/doc/user/clusters/agent/ci_cd_tunnel.md
index c6dbb1ca70c..73a8470e025 100644
--- a/doc/user/clusters/agent/ci_cd_tunnel.md
+++ b/doc/user/clusters/agent/ci_cd_tunnel.md
@@ -11,6 +11,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - The ability to authorize groups was [introduced](https://gitlab.com/groups/gitlab-org/-/epics/5784) in GitLab 14.3.
> - [Moved](https://gitlab.com/groups/gitlab-org/-/epics/6290) to GitLab Free in 14.5.
> - Support for Omnibus installations was [introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/-/merge_requests/5686) in GitLab 14.5.
+> - The ability to switch between certificate-based clusters and agents was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/335089) in GitLab 14.9. The certificate-based cluster context is always called `gitlab-deploy`.
You can use a GitLab CI/CD workflow to safely deploy to and update your Kubernetes clusters.
@@ -117,6 +118,37 @@ Use the format `path/to/agent/repository:agent-name`. For example:
If you are not sure what your agent's context is, open a terminal and connect to your cluster.
Run `kubectl config get-contexts`.
+### Environments with both certificate-based and agent-based connections
+
+When you deploy to an environment that has both a [certificate-based
+cluster](../../infrastructure/clusters/index.md) (deprecated) and an agent connection:
+
+- The certificate-based cluster's context is called `gitlab-deploy`. This context
+ is always selected by default.
+- In GitLab 14.9 and later, agent contexts are included in the
+ `KUBECONFIG`. You can select them by using `kubectl config use-context
+ path/to/agent/repository:agent-name`.
+- In GitLab 14.8 and earlier, you can still use agent connections, but for environments that
+ already have a certificate-based cluster, the agent connections are not included in the `KUBECONFIG`.
+
+To use an agent connection when certificate-based connections are present, you can manually configure a new `kubectl`
+configuration context. For example:
+
+ ```yaml
+ deploy:
+ variables:
+ KUBE_CONTEXT: my-context # The name to use for the new context
+ AGENT_ID: 1234 # replace with your agent's numeric ID
+ K8S_PROXY_URL: wss://kas.gitlab.com/k8s-proxy/ # replace with your agent server (KAS) Kubernetes proxy URL
+ # ... any other variables you have configured
+ before_script:
+ - kubectl config set-credentials agent:$AGENT_ID --token="ci:${AGENT_ID}:${CI_JOB_TOKEN}"
+ - kubectl config set-cluster gitlab --server="${K8S_PROXY_URL}"
+ - kubectl config set-context "$KUBE_CONTEXT" --cluster=gitlab --user="agent:${AGENT_ID}"
+ - kubectl config use-context "$KUBE_CONTEXT"
+ # ... rest of your job configuration
+ ```
+
## Use impersonation to restrict project and group access **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345014) in GitLab 14.5.
diff --git a/lib/api/ci/runners.rb b/lib/api/ci/runners.rb
index c3f2c5bcd72..3c9e887e751 100644
--- a/lib/api/ci/runners.rb
+++ b/lib/api/ci/runners.rb
@@ -246,9 +246,9 @@ module API
success Entities::Ci::ResetTokenResult
end
post 'reset_registration_token' do
- authorize! :update_runners_registration_token
+ authorize! :update_runners_registration_token, ApplicationSetting.current
- ApplicationSetting.current.reset_runners_registration_token!
+ ::Ci::Runners::ResetRegistrationTokenService.new(ApplicationSetting.current, current_user).execute
present ApplicationSetting.current_without_cache.runners_registration_token_with_expiration, with: Entities::Ci::ResetTokenResult
end
end
diff --git a/lib/gitlab/analytics/cycle_analytics/request_params.rb b/lib/gitlab/analytics/cycle_analytics/request_params.rb
index bc270fd784a..623f9d8082d 100644
--- a/lib/gitlab/analytics/cycle_analytics/request_params.rb
+++ b/lib/gitlab/analytics/cycle_analytics/request_params.rb
@@ -108,8 +108,8 @@ module Gitlab
aggregation = ::Analytics::CycleAnalytics::Aggregation.safe_create_for_group(group)
{
enabled: aggregation.enabled.to_s,
- last_run_at: aggregation.last_incremental_run_at,
- next_run_at: aggregation.estimated_next_run_at
+ last_run_at: aggregation.last_incremental_run_at&.iso8601,
+ next_run_at: aggregation.estimated_next_run_at&.iso8601
}
end
diff --git a/lib/gitlab/safe_request_loader.rb b/lib/gitlab/safe_request_loader.rb
new file mode 100644
index 00000000000..89eca16c272
--- /dev/null
+++ b/lib/gitlab/safe_request_loader.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class SafeRequestLoader
+ def self.execute(args, &block)
+ new(**args).execute(&block)
+ end
+
+ def initialize(resource_key:, resource_ids:, default_value: nil)
+ @resource_key = resource_key
+ @resource_ids = resource_ids.uniq
+ @default_value = default_value
+ @resource_data = {}
+ end
+
+ def execute(&block)
+ raise ArgumentError, 'Block is mandatory' unless block_given?
+
+ load_resource_data
+ remove_loaded_resource_ids
+
+ update_resource_data(&block)
+
+ resource_data
+ end
+
+ private
+
+ attr_reader :resource_key, :resource_ids, :default_value, :resource_data, :missing_resource_ids
+
+ def load_resource_data
+ @resource_data = Gitlab::SafeRequestStore.fetch(resource_key) { resource_data }
+ end
+
+ def remove_loaded_resource_ids
+ # Look up only the IDs we need
+ @missing_resource_ids = resource_ids - resource_data.keys
+ end
+
+ def update_resource_data(&block)
+ return if missing_resource_ids.blank?
+
+ reloaded_resource_data = yield(missing_resource_ids)
+
+ @resource_data.merge!(reloaded_resource_data)
+
+ mark_absent_values
+ end
+
+ def mark_absent_values
+ absent = (missing_resource_ids - resource_data.keys).to_h { [_1, default_value] }
+ @resource_data.merge!(absent)
+ end
+ end
+end
diff --git a/lib/sidebars/groups/menus/kubernetes_menu.rb b/lib/sidebars/groups/menus/kubernetes_menu.rb
index 4ea294a4837..98ca7865995 100644
--- a/lib/sidebars/groups/menus/kubernetes_menu.rb
+++ b/lib/sidebars/groups/menus/kubernetes_menu.rb
@@ -21,7 +21,10 @@ module Sidebars
override :render?
def render?
- can?(context.current_user, :read_cluster, context.group)
+ clusterable = context.group
+
+ Feature.enabled?(:certificate_based_clusters, clusterable, default_enabled: :yaml, type: :ops) &&
+ can?(context.current_user, :read_cluster, clusterable)
end
override :extra_container_html_options
diff --git a/lib/tasks/gitlab/tw/codeowners.rake b/lib/tasks/gitlab/tw/codeowners.rake
index 43fd4f8685a..358bc6c31eb 100644
--- a/lib/tasks/gitlab/tw/codeowners.rake
+++ b/lib/tasks/gitlab/tw/codeowners.rake
@@ -22,7 +22,7 @@ namespace :tw do
CodeOwnerRule.new('Container Security', '@ngaskill'),
CodeOwnerRule.new('Contributor Experience', '@eread'),
CodeOwnerRule.new('Conversion', '@kpaizee'),
- CodeOwnerRule.new('Database', '@marcia'),
+ CodeOwnerRule.new('Database', '@aqualls'),
CodeOwnerRule.new('Development', '@marcia'),
CodeOwnerRule.new('Distribution', '@axil'),
CodeOwnerRule.new('Distribution (Charts)', '@axil'),
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e037fc64d9e..cb0256a5441 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -11448,6 +11448,9 @@ msgstr ""
msgid "Data is still calculating..."
msgstr ""
+msgid "Data refresh"
+msgstr ""
+
msgid "Data type"
msgstr ""
@@ -21580,6 +21583,9 @@ msgstr ""
msgid "Last updated"
msgstr ""
+msgid "Last updated %{time} ago"
+msgstr ""
+
msgid "Last used"
msgstr ""
@@ -24643,6 +24649,9 @@ msgstr ""
msgid "Next unresolved discussion"
msgstr ""
+msgid "Next update"
+msgstr ""
+
msgid "Nickname"
msgstr ""
diff --git a/spec/controllers/admin/clusters_controller_spec.rb b/spec/controllers/admin/clusters_controller_spec.rb
index 1c8d6b937b2..fed9d2e8588 100644
--- a/spec/controllers/admin/clusters_controller_spec.rb
+++ b/spec/controllers/admin/clusters_controller_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Admin::ClustersController do
create(:cluster, :disabled, :provided_by_gcp, :production_environment, :instance)
end
- include_examples ':certificate_based_clusters feature flag index responses' do
+ include_examples ':certificate_based_clusters feature flag controller responses' do
let(:subject) { get_index }
end
diff --git a/spec/controllers/groups/clusters_controller_spec.rb b/spec/controllers/groups/clusters_controller_spec.rb
index facf4e4625b..4eeae64b760 100644
--- a/spec/controllers/groups/clusters_controller_spec.rb
+++ b/spec/controllers/groups/clusters_controller_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Groups::ClustersController do
create(:cluster, :disabled, :provided_by_gcp, :production_environment, cluster_type: :group_type, groups: [group])
end
- include_examples ':certificate_based_clusters feature flag index responses' do
+ include_examples ':certificate_based_clusters feature flag controller responses' do
let(:subject) { go }
end
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index 88c06b3857a..e0b61526ba0 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -25,5 +25,9 @@ FactoryBot.define do
feature_flag_events { true }
releases_events { true }
end
+
+ trait :with_push_branch_filter do
+ push_events_branch_filter { 'my-branch-*' }
+ end
end
end
diff --git a/spec/features/users/terms_spec.rb b/spec/features/users/terms_spec.rb
index 5fde445a775..7a662d24d60 100644
--- a/spec/features/users/terms_spec.rb
+++ b/spec/features/users/terms_spec.rb
@@ -99,7 +99,7 @@ RSpec.describe 'Users > Terms', :js do
enforce_terms
# Application settings are cached for a minute
- Timecop.travel 2.minutes do
+ travel_to 2.minutes.from_now do
within('.nav-sidebar') do
click_link 'Issues'
end
diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js
index 2cbd12a680b..312df12ab5f 100644
--- a/spec/frontend/clusters_list/components/clusters_actions_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersActions from '~/clusters_list/components/clusters_actions.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -15,6 +15,7 @@ describe('ClustersActionsComponent', () => {
addClusterPath,
canAddCluster: true,
displayClusterAgents: true,
+ certificateBasedClustersEnabled: true,
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
@@ -24,6 +25,7 @@ describe('ClustersActionsComponent', () => {
const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link');
const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link');
const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link');
+ const findConnectWithAgentButton = () => wrapper.findComponent(GlButton);
const createWrapper = (provideData = {}) => {
wrapper = shallowMountExtended(ClustersActions, {
@@ -45,90 +47,110 @@ describe('ClustersActionsComponent', () => {
afterEach(() => {
wrapper.destroy();
});
+ describe('when the certificate based clusters are enabled', () => {
+ it('renders actions menu', () => {
+ expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton);
+ });
- it('renders actions menu', () => {
- expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton);
- });
+ it('renders correct href attributes for the links', () => {
+ expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
+ expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
+ });
- it('renders correct href attributes for the links', () => {
- expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
- expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
- });
+ describe('when user cannot add clusters', () => {
+ beforeEach(() => {
+ createWrapper({ canAddCluster: false });
+ });
- describe('when user cannot add clusters', () => {
- beforeEach(() => {
- createWrapper({ canAddCluster: false });
- });
+ it('disables dropdown', () => {
+ expect(findDropdown().props('disabled')).toBe(true);
+ });
- it('disables dropdown', () => {
- expect(findDropdown().props('disabled')).toBe(true);
- });
+ it('shows tooltip explaining why dropdown is disabled', () => {
+ const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
+ });
+
+ it('does not bind split dropdown button', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
- it('shows tooltip explaining why dropdown is disabled', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
+ expect(binding.value).toBe(false);
+ });
});
- it('does not bind split dropdown button', () => {
- const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+ describe('when on project level', () => {
+ it('renders a dropdown with 3 actions items', () => {
+ expect(findDropdownItemIds()).toEqual([
+ 'connect-new-agent-link',
+ 'new-cluster-link',
+ 'connect-cluster-link',
+ ]);
+ });
- expect(binding.value).toBe(false);
- });
- });
+ it('renders correct modal id for the agent link', () => {
+ const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
- describe('when on project level', () => {
- it('renders a dropdown with 3 actions items', () => {
- expect(findDropdownItemIds()).toEqual([
- 'connect-new-agent-link',
- 'new-cluster-link',
- 'connect-cluster-link',
- ]);
- });
+ expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
+ });
- it('renders correct modal id for the agent link', () => {
- const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
+ it('shows tooltip', () => {
+ const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
+ });
- expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
- });
+ it('shows split button in dropdown', () => {
+ expect(findDropdown().props('split')).toBe(true);
+ });
- it('shows tooltip', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
- });
+ it('binds split button with modal id', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
- it('shows split button in dropdown', () => {
- expect(findDropdown().props('split')).toBe(true);
+ expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
+ });
});
- it('binds split button with modal id', () => {
- const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+ describe('when on group or admin level', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: false });
+ });
- expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
- });
- });
+ it('renders a dropdown with 2 actions items', () => {
+ expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']);
+ });
- describe('when on group or admin level', () => {
- beforeEach(() => {
- createWrapper({ displayClusterAgents: false });
- });
+ it('shows tooltip', () => {
+ const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster);
+ });
- it('renders a dropdown with 2 actions items', () => {
- expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']);
- });
+ it('does not show split button in dropdown', () => {
+ expect(findDropdown().props('split')).toBe(false);
+ });
+
+ it('does not bind dropdown button to modal', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
- it('shows tooltip', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster);
+ expect(binding.value).toBe(false);
+ });
});
+ });
- it('does not show split button in dropdown', () => {
- expect(findDropdown().props('split')).toBe(false);
+ describe('when the certificate based clusters not enabled', () => {
+ beforeEach(() => {
+ createWrapper({ certificateBasedClustersEnabled: false });
});
- it('does not bind dropdown button to modal', () => {
- const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+ it('it does not show the the dropdown', () => {
+ expect(findDropdown().exists()).toBe(false);
+ });
- expect(binding.value).toBe(false);
+ it('shows the connect with agent button', () => {
+ expect(findConnectWithAgentButton().props()).toMatchObject({
+ disabled: !defaultProvide.canAddCluster,
+ category: 'primary',
+ variant: 'confirm',
+ });
+ expect(findConnectWithAgentButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent);
});
});
});
diff --git a/spec/frontend/clusters_list/components/clusters_main_view_spec.js b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
index 20754ffe5b7..218463b9adf 100644
--- a/spec/frontend/clusters_list/components/clusters_main_view_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
@@ -6,6 +6,7 @@ import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vu
import {
AGENT,
CERTIFICATE_BASED,
+ AGENT_TAB,
CLUSTERS_TABS,
CERTIFICATE_TAB,
MAX_CLUSTERS_LIST,
@@ -24,10 +25,18 @@ describe('ClustersMainViewComponent', () => {
defaultBranchName,
};
- const createWrapper = ({ displayClusterAgents }) => {
+ const defaultProvide = {
+ certificateBasedClustersEnabled: true,
+ displayClusterAgents: true,
+ };
+
+ const createWrapper = (extendedProvide = {}) => {
wrapper = shallowMountExtended(ClustersMainView, {
propsData,
- provide: { displayClusterAgents },
+ provide: {
+ ...defaultProvide,
+ ...extendedProvide,
+ },
});
};
@@ -40,91 +49,111 @@ describe('ClustersMainViewComponent', () => {
const findGlTabAtIndex = (index) => findAllTabs().at(index);
const findComponent = () => wrapper.findByTestId('clusters-tab-component');
const findModal = () => wrapper.findComponent(InstallAgentModal);
+ describe('when the certificate based clusters are enabled', () => {
+ describe('when on project level', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: true });
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
- describe('when on project level', () => {
- beforeEach(() => {
- createWrapper({ displayClusterAgents: true });
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- });
+ it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => {
+ expect(findTabs().exists()).toBe(true);
+ expect(findTabs().props('syncActiveTabWithQueryParams')).toBe(true);
+ });
- it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', () => {
- expect(findTabs().exists()).toBe(true);
- expect(findTabs().props('syncActiveTabWithQueryParams')).toBe(true);
- });
+ it('renders correct number of tabs', () => {
+ expect(findAllTabs()).toHaveLength(CLUSTERS_TABS.length);
+ });
- it('renders correct number of tabs', () => {
- expect(findAllTabs()).toHaveLength(CLUSTERS_TABS.length);
- });
+ describe('tabs', () => {
+ it.each`
+ tabTitle | queryParamValue | lineNumber
+ ${'All'} | ${'all'} | ${0}
+ ${'Agent'} | ${AGENT} | ${1}
+ ${'Certificate'} | ${CERTIFICATE_BASED} | ${2}
+ `(
+ 'renders correct tab title and query param value',
+ ({ tabTitle, queryParamValue, lineNumber }) => {
+ expect(findGlTabAtIndex(lineNumber).attributes('title')).toBe(tabTitle);
+ expect(findGlTabAtIndex(lineNumber).props('queryParamValue')).toBe(queryParamValue);
+ },
+ );
+ });
- describe('tabs', () => {
- it.each`
- tabTitle | queryParamValue | lineNumber
- ${'All'} | ${'all'} | ${0}
- ${'Agent'} | ${AGENT} | ${1}
- ${'Certificate'} | ${CERTIFICATE_BASED} | ${2}
+ describe.each`
+ tab | tabName
+ ${'1'} | ${AGENT}
+ ${'2'} | ${CERTIFICATE_BASED}
`(
- 'renders correct tab title and query param value',
- ({ tabTitle, queryParamValue, lineNumber }) => {
- expect(findGlTabAtIndex(lineNumber).attributes('title')).toBe(tabTitle);
- expect(findGlTabAtIndex(lineNumber).props('queryParamValue')).toBe(queryParamValue);
+ 'when the child component emits the tab change event for $tabName tab',
+ ({ tab, tabName }) => {
+ beforeEach(() => {
+ findComponent().vm.$emit('changeTab', tabName);
+ });
+
+ it(`changes the tab value to ${tab}`, () => {
+ expect(findTabs().attributes('value')).toBe(tab);
+ });
},
);
- });
- describe.each`
- tab | tabName
- ${'1'} | ${AGENT}
- ${'2'} | ${CERTIFICATE_BASED}
- `(
- 'when the child component emits the tab change event for $tabName tab',
- ({ tab, tabName }) => {
+ describe.each`
+ tab | tabName | maxAgents
+ ${1} | ${AGENT} | ${MAX_LIST_COUNT}
+ ${2} | ${CERTIFICATE_BASED} | ${MAX_CLUSTERS_LIST}
+ `('when the active tab is $tabName', ({ tab, tabName, maxAgents }) => {
beforeEach(() => {
- findComponent().vm.$emit('changeTab', tabName);
+ findTabs().vm.$emit('input', tab);
});
- it(`changes the tab value to ${tab}`, () => {
- expect(findTabs().attributes('value')).toBe(tab);
+ it('passes child-component param to the component', () => {
+ expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName);
});
- },
- );
- describe.each`
- tab | tabName | maxAgents
- ${1} | ${AGENT} | ${MAX_LIST_COUNT}
- ${2} | ${CERTIFICATE_BASED} | ${MAX_CLUSTERS_LIST}
- `('when the active tab is $tabName', ({ tab, tabName, maxAgents }) => {
- beforeEach(() => {
- findTabs().vm.$emit('input', tab);
+ it(`sets max-agents param to ${maxAgents} and passes it to the modal`, () => {
+ expect(findModal().props('maxAgents')).toBe(maxAgents);
+ });
+
+ it(`sends the correct tracking event with the property '${tabName}'`, () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, {
+ label: EVENT_LABEL_TABS,
+ property: tabName,
+ });
+ });
});
+ });
- it('passes child-component param to the component', () => {
- expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName);
+ describe('when on group or admin level', () => {
+ beforeEach(() => {
+ createWrapper({ displayClusterAgents: false });
});
- it(`sets max-agents param to ${maxAgents} and passes it to the modal`, () => {
- expect(findModal().props('maxAgents')).toBe(maxAgents);
+ it('renders correct number of tabs', () => {
+ expect(findAllTabs()).toHaveLength(1);
});
- it(`sends the correct tracking event with the property '${tabName}'`, () => {
- expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, {
- label: EVENT_LABEL_TABS,
- property: tabName,
- });
+ it('renders correct tab title', () => {
+ expect(findGlTabAtIndex(0).attributes('title')).toBe(CERTIFICATE_TAB.title);
});
});
- });
- describe('when on group or admin level', () => {
- beforeEach(() => {
- createWrapper({ displayClusterAgents: false });
- });
+ describe('when the certificate based clusters not enabled', () => {
+ beforeEach(() => {
+ createWrapper({ certificateBasedClustersEnabled: false });
+ });
- it('renders correct number of tabs', () => {
- expect(findAllTabs()).toHaveLength(1);
- });
+ it('it displays only the Agent tab', () => {
+ expect(findAllTabs()).toHaveLength(1);
+ const agentTab = findGlTabAtIndex(0);
- it('renders correct tab title', () => {
- expect(findGlTabAtIndex(0).attributes('title')).toBe(CERTIFICATE_TAB.title);
+ expect(agentTab.props()).toMatchObject({
+ queryParamValue: AGENT_TAB.queryParamValue,
+ titleLinkClass: '',
+ });
+ expect(agentTab.attributes()).toMatchObject({
+ title: AGENT_TAB.title,
+ });
+ });
});
});
});
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index 324ed17cc66..3bc72b13302 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -5,18 +5,26 @@ import {
} from '~/content_editor/constants';
import { ContentEditor } from '~/content_editor/services/content_editor';
import eventHubFactory from '~/helpers/event_hub_factory';
-import { createTestEditor } from '../test_utils';
+import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let deserializer;
let eventHub;
+ let doc;
+ let p;
beforeEach(() => {
const tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'destroy');
+ ({
+ builders: { doc, p },
+ } = createDocBuilder({
+ tiptapEditor,
+ }));
+
serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() };
eventHub = eventHubFactory();
@@ -34,8 +42,11 @@ describe('content_editor/services/content_editor', () => {
});
describe('when setSerializedContent succeeds', () => {
+ let document;
+
beforeEach(() => {
- deserializer.deserialize.mockResolvedValueOnce('');
+ document = doc(p('document'));
+ deserializer.deserialize.mockResolvedValueOnce({ document });
});
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
@@ -50,6 +61,12 @@ describe('content_editor/services/content_editor', () => {
contentEditor.setSerializedContent('**bold text**');
});
+
+ it('sets the deserialized document in the tiptap editor object', async () => {
+ await contentEditor.setSerializedContent('**bold text**');
+
+ expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
+ });
});
describe('when setSerializedContent fails', () => {
diff --git a/spec/frontend/content_editor/services/markdown_deserializer_spec.js b/spec/frontend/content_editor/services/markdown_deserializer_spec.js
index dcd5b2eea25..bea43a0effc 100644
--- a/spec/frontend/content_editor/services/markdown_deserializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_deserializer_spec.js
@@ -25,27 +25,38 @@ describe('content_editor/services/markdown_deserializer', () => {
renderMarkdown = jest.fn();
});
- it('transforms HTML returned by render function to a ProseMirror document', async () => {
- const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- const expectedDoc = doc(p(bold('Bold text')));
+ describe('when deserializing', () => {
+ let result;
+ const text = 'Bold text';
- renderMarkdown.mockResolvedValueOnce('<p><strong>Bold text</strong></p>');
+ beforeEach(async () => {
+ const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+
+ renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`);
- const result = await deserializer.deserialize({
- content: 'content',
- schema: tiptapEditor.schema,
+ result = await deserializer.deserialize({
+ content: 'content',
+ schema: tiptapEditor.schema,
+ });
});
+ it('transforms HTML returned by render function to a ProseMirror document', async () => {
+ const expectedDoc = doc(p(bold(text)));
- expect(result.toJSON()).toEqual(expectedDoc.toJSON());
+ expect(result.document.toJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ it('returns parsed HTML as a DOM object', () => {
+ expect(result.dom.innerHTML).toEqual(`<p><strong>${text}</strong></p><!--content-->`);
+ });
});
describe('when the render function returns an empty value', () => {
- it('also returns null', async () => {
+ it('returns an empty object', async () => {
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
renderMarkdown.mockResolvedValueOnce(null);
- expect(await deserializer.deserialize({ content: 'content' })).toBe(null);
+ expect(await deserializer.deserialize({ content: 'content' })).toEqual({});
});
});
});
diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
index 6dc6499f3f2..abd9588daff 100644
--- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -73,7 +73,7 @@ describe('content_editor/services/markdown_sourcemap', () => {
});
it('gets markdown source for a rendered HTML element', async () => {
- const deserialized = await markdownDeserializer({
+ const { document } = await markdownDeserializer({
render: () => BULLET_LIST_HTML,
}).deserialize({
schema: tiptapEditor.schema,
@@ -95,6 +95,6 @@ describe('content_editor/services/markdown_sourcemap', () => {
),
);
- expect(deserialized.toJSON()).toEqual(expected.toJSON());
+ expect(document.toJSON()).toEqual(expected.toJSON());
});
});
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index c352fe1bdca..26d48bef24e 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -293,4 +293,25 @@ RSpec.describe ApplicationSettingsHelper do
it { is_expected.to eq([%w(Track track), %w(Compress compress)]) }
end
+
+ describe '#instance_clusters_enabled?' do
+ let_it_be(:user) { create(:user) }
+
+ subject { helper.instance_clusters_enabled? }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?).with(user, :read_cluster, instance_of(Clusters::Instance)).and_return(true)
+ end
+
+ it { is_expected.to be_truthy}
+
+ context ':certificate_based_clusters feature flag is disabled' do
+ before do
+ stub_feature_flags(certificate_based_clusters: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index 433de813e3d..0d8713815b8 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -136,6 +136,28 @@ RSpec.describe ClustersHelper do
expect(subject[:display_cluster_agents]).to eq("false")
end
end
+
+ describe 'certificate based clusters enabled' do
+ before do
+ stub_feature_flags(certificate_based_clusters: flag_enabled)
+ end
+
+ context 'feature flag is enabled' do
+ let(:flag_enabled) { true }
+
+ it do
+ expect(subject[:certificate_based_clusters_enabled]).to eq('true')
+ end
+ end
+
+ context 'feature flag is disabled' do
+ let(:flag_enabled) { false }
+
+ it do
+ expect(subject[:certificate_based_clusters_enabled]).to eq('false')
+ end
+ end
+ end
end
describe '#js_clusters_data' do
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index 5080d21d564..90c0684f8b7 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -257,7 +257,7 @@ RSpec.describe Feature, stub_feature_flags: false do
end
it 'caches the status in L2 cache after 2 minutes' do
- Timecop.travel 2.minutes do
+ travel_to 2.minutes.from_now do
expect do
expect(described_class.send(:l1_cache_backend)).to receive(:fetch).once.and_call_original
expect(described_class.send(:l2_cache_backend)).to receive(:fetch).once.and_call_original
@@ -267,7 +267,7 @@ RSpec.describe Feature, stub_feature_flags: false do
end
it 'fetches the status after an hour' do
- Timecop.travel 61.minutes do
+ travel_to 61.minutes.from_now do
expect do
expect(described_class.send(:l1_cache_backend)).to receive(:fetch).once.and_call_original
expect(described_class.send(:l2_cache_backend)).to receive(:fetch).once.and_call_original
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb
index 14768025932..b4aa843bcd7 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/median_spec.rb
@@ -30,11 +30,11 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::Median do
merge_request1 = create(:merge_request, source_branch: '1', target_project: project, source_project: project)
merge_request2 = create(:merge_request, source_branch: '2', target_project: project, source_project: project)
- Timecop.travel(5.minutes.from_now) do
+ travel_to(5.minutes.from_now) do
merge_request1.metrics.update!(merged_at: Time.zone.now)
end
- Timecop.travel(10.minutes.from_now) do
+ travel_to(10.minutes.from_now) do
merge_request2.metrics.update!(merged_at: Time.zone.now)
end
diff --git a/spec/lib/gitlab/safe_request_loader_spec.rb b/spec/lib/gitlab/safe_request_loader_spec.rb
new file mode 100644
index 00000000000..504ce233e4d
--- /dev/null
+++ b/spec/lib/gitlab/safe_request_loader_spec.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SafeRequestLoader, :aggregate_failures do
+ let(:resource_key) { '_key_' }
+ let(:resource_ids) { [] }
+ let(:args) { { resource_key: resource_key, resource_ids: resource_ids } }
+ let(:block) { proc { {} } }
+
+ describe '.execute', :request_store do
+ let(:resource_data) { { 'foo' => 'bar' } }
+
+ before do
+ Gitlab::SafeRequestStore[resource_key] = resource_data
+ end
+
+ subject(:execute_instance) { described_class.execute(**args, &block) }
+
+ it 'gets data from the store and returns it' do
+ expect(execute_instance.keys).to contain_exactly(*resource_data.keys)
+ expect(execute_instance).to match(a_hash_including(resource_data))
+ expect_store_to_be_updated
+ end
+ end
+
+ describe '#execute' do
+ subject(:execute_instance) { described_class.new(**args).execute(&block) }
+
+ context 'without a block' do
+ let(:block) { nil }
+
+ it 'raises an error' do
+ expect { execute_instance }.to raise_error(ArgumentError, 'Block is mandatory')
+ end
+ end
+
+ context 'when a resource_id is nil' do
+ let(:block) { proc { {} } }
+ let(:resource_ids) { [nil] }
+
+ it 'contains resource_data with nil key' do
+ expect(execute_instance.keys).to contain_exactly(nil)
+ expect(execute_instance).to match(a_hash_including(nil => nil))
+ end
+ end
+
+ context 'with SafeRequestStore considerations' do
+ let(:resource_data) { { 'foo' => 'bar' } }
+
+ before do
+ Gitlab::SafeRequestStore[resource_key] = resource_data
+ end
+
+ context 'when request store is active', :request_store do
+ it 'gets data from the store' do
+ expect(execute_instance.keys).to contain_exactly(*resource_data.keys)
+ expect(execute_instance).to match(a_hash_including(resource_data))
+ expect_store_to_be_updated
+ end
+
+ context 'with already loaded resource_ids', :request_store do
+ let(:resource_key) { 'foo_data' }
+ let(:existing_resource_data) { { 'foo' => 'zoo' } }
+ let(:block) { proc { { 'foo' => 'bar' } } }
+ let(:resource_ids) { ['foo'] }
+
+ before do
+ Gitlab::SafeRequestStore[resource_key] = existing_resource_data
+ end
+
+ it 'does not re-fetch data if resource_id already exists' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including(existing_resource_data))
+ expect_store_to_be_updated
+ end
+
+ context 'with mixture of new and existing resource_ids' do
+ let(:existing_resource_data) { { 'foo' => 'bar' } }
+ let(:resource_ids) { %w[foo bar] }
+
+ context 'when block does not filter for only the missing resource_ids' do
+ let(:block) { proc { { 'foo' => 'zoo', 'bar' => 'foo' } } }
+
+ it 'overwrites existing keyed data with results from the block' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including(block.call))
+ expect_store_to_be_updated
+ end
+ end
+
+ context 'when passing the missing resource_ids to a block that filters for them' do
+ let(:block) { proc { |rids| { 'foo' => 'zoo', 'bar' => 'foo' }.select { |k, _v| rids.include?(k) } } }
+
+ it 'only updates resource_data with keyed items that did not exist' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including({ 'foo' => 'bar', 'bar' => 'foo' }))
+ expect_store_to_be_updated
+ end
+ end
+
+ context 'with default_value for resource_ids that did not exist in the results' do
+ context 'when default_value is provided' do
+ let(:args) { { resource_key: resource_key, resource_ids: resource_ids, default_value: '_value_' } }
+
+ it 'populates a default value' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including({ 'foo' => 'bar', 'bar' => '_value_' }))
+ expect_store_to_be_updated
+ end
+ end
+
+ context 'when default_value is not provided' do
+ it 'populates a default_value of nil' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including({ 'foo' => 'bar', 'bar' => nil }))
+ expect_store_to_be_updated
+ end
+ end
+ end
+ end
+ end
+ end
+
+ context 'when request store is not active' do
+ let(:block) { proc { { 'foo' => 'bar' } } }
+ let(:resource_ids) { ['foo'] }
+
+ it 'has no data added from the store' do
+ expect(execute_instance).to eq(block.call)
+ end
+
+ context 'with mixture of new and existing resource_ids' do
+ let(:resource_ids) { %w[foo bar] }
+
+ context 'when block does not filter out existing resource_data keys' do
+ let(:block) { proc { { 'foo' => 'zoo', 'bar' => 'foo' } } }
+
+ it 'overwrites existing keyed data with results from the block' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including(block.call))
+ end
+ end
+
+ context 'when passing the missing resource_ids to a block that filters for them' do
+ let(:block) { proc { |rids| { 'foo' => 'zoo', 'bar' => 'foo' }.select { |k, _v| rids.include?(k) } } }
+
+ it 'only updates resource_data with keyed items that did not exist' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including({ 'foo' => 'zoo', 'bar' => 'foo' }))
+ end
+ end
+
+ context 'with default_value for resource_ids that did not exist in the results' do
+ context 'when default_value is provided' do
+ let(:args) { { resource_key: resource_key, resource_ids: resource_ids, default_value: '_value_' } }
+
+ it 'populates a default value' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including({ 'foo' => 'bar', 'bar' => '_value_' }))
+ end
+ end
+
+ context 'when default_value is not provided' do
+ it 'populates a default_value of nil' do
+ expect(execute_instance.keys).to contain_exactly(*resource_ids)
+ expect(execute_instance).to match(a_hash_including({ 'foo' => 'bar', 'bar' => nil }))
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def expect_store_to_be_updated
+ expect(execute_instance).to match(a_hash_including(Gitlab::SafeRequestStore[resource_key]))
+ expect(execute_instance.keys).to contain_exactly(*Gitlab::SafeRequestStore[resource_key].keys)
+ end
+end
diff --git a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
index 76e58367c9d..36d5b3376b7 100644
--- a/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/kubernetes_menu_spec.rb
@@ -28,5 +28,15 @@ RSpec.describe Sidebars::Groups::Menus::KubernetesMenu do
expect(menu.render?).to eq false
end
end
+
+ context ':certificate_based_clusters feature flag is disabled' do
+ before do
+ stub_feature_flags(certificate_based_clusters: false)
+ end
+
+ it 'returns false' do
+ expect(menu.render?).to eq false
+ end
+ end
end
end
diff --git a/spec/migrations/20210812013042_remove_duplicate_project_authorizations_spec.rb b/spec/migrations/20210812013042_remove_duplicate_project_authorizations_spec.rb
new file mode 100644
index 00000000000..f734456b0b6
--- /dev/null
+++ b/spec/migrations/20210812013042_remove_duplicate_project_authorizations_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('remove_duplicate_project_authorizations')
+
+RSpec.describe RemoveDuplicateProjectAuthorizations, :migration do
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:project_authorizations) { table(:project_authorizations) }
+
+ let!(:user_1) { users.create! email: 'user1@example.com', projects_limit: 0 }
+ let!(:user_2) { users.create! email: 'user2@example.com', projects_limit: 0 }
+ let!(:namespace_1) { namespaces.create! name: 'namespace 1', path: 'namespace1' }
+ let!(:namespace_2) { namespaces.create! name: 'namespace 2', path: 'namespace2' }
+ let!(:project_1) { projects.create! namespace_id: namespace_1.id }
+ let!(:project_2) { projects.create! namespace_id: namespace_2.id }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ describe '#up' do
+ subject { migrate! }
+
+ context 'User with multiple projects' do
+ before do
+ project_authorizations.create! project_id: project_1.id, user_id: user_1.id, access_level: Gitlab::Access::DEVELOPER
+ project_authorizations.create! project_id: project_2.id, user_id: user_1.id, access_level: Gitlab::Access::DEVELOPER
+ end
+
+ it { expect { subject }.not_to change { ProjectAuthorization.count } }
+ end
+
+ context 'Project with multiple users' do
+ before do
+ project_authorizations.create! project_id: project_1.id, user_id: user_1.id, access_level: Gitlab::Access::DEVELOPER
+ project_authorizations.create! project_id: project_1.id, user_id: user_2.id, access_level: Gitlab::Access::DEVELOPER
+ end
+
+ it { expect { subject }.not_to change { ProjectAuthorization.count } }
+ end
+
+ context 'Same project and user but different access level' do
+ before do
+ project_authorizations.create! project_id: project_1.id, user_id: user_1.id, access_level: Gitlab::Access::DEVELOPER
+ project_authorizations.create! project_id: project_1.id, user_id: user_1.id, access_level: Gitlab::Access::MAINTAINER
+ project_authorizations.create! project_id: project_1.id, user_id: user_1.id, access_level: Gitlab::Access::REPORTER
+ end
+
+ it { expect { subject }.to change { ProjectAuthorization.count}.from(3).to(1) }
+
+ it 'retains the highest access level' do
+ subject
+
+ all_records = ProjectAuthorization.all.to_a
+ expect(all_records.count).to eq 1
+ expect(all_records.first.access_level).to eq Gitlab::Access::MAINTAINER
+ end
+ end
+ end
+end
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index 5c8f41f19fb..b0bfdabe13c 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe BroadcastMessage do
subject.call
- Timecop.travel(3.weeks) do
+ travel_to(3.weeks.from_now) do
subject.call
end
end
@@ -73,7 +73,7 @@ RSpec.describe BroadcastMessage do
expect(subject.call).to match_array([message])
expect(described_class.cache).to receive(:expire).and_call_original
- Timecop.travel(1.week) do
+ travel_to(1.week.from_now) do
2.times { expect(subject.call).to be_empty }
end
end
@@ -96,7 +96,7 @@ RSpec.describe BroadcastMessage do
expect(subject.call.length).to eq(1)
- Timecop.travel(future.starts_at) do
+ travel_to(future.starts_at + 1.second) do
expect(subject.call.length).to eq(2)
end
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index cf405c08ec8..e3c0e3a7a2b 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -798,7 +798,7 @@ RSpec.describe Issuable do
it 'updates issues updated_at' do
issue
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
expect { spend_time(1800) }.to change { issue.updated_at }
end
end
@@ -823,7 +823,7 @@ RSpec.describe Issuable do
context 'when time to subtract exceeds the total time spent' do
it 'raise a validation error' do
- Timecop.travel(1.minute.from_now) do
+ travel_to(1.minute.from_now) do
expect do
expect do
spend_time(-3600)
diff --git a/spec/models/project_authorization_spec.rb b/spec/models/project_authorization_spec.rb
index 37da30fb54c..14220007966 100644
--- a/spec/models/project_authorization_spec.rb
+++ b/spec/models/project_authorization_spec.rb
@@ -3,6 +3,56 @@
require 'spec_helper'
RSpec.describe ProjectAuthorization do
+ describe 'unique user, project authorizations' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project_1) { create(:project) }
+
+ let!(:project_auth) do
+ create(
+ :project_authorization,
+ user: user,
+ project: project_1,
+ access_level: Gitlab::Access::DEVELOPER
+ )
+ end
+
+ context 'with duplicate user and project authorization' do
+ subject { project_auth.dup }
+
+ it { is_expected.to be_invalid }
+
+ context 'after validation' do
+ before do
+ subject.valid?
+ end
+
+ it 'contains duplicate error' do
+ expect(subject.errors[:user]).to include('has already been taken')
+ end
+ end
+ end
+
+ context 'with multiple access levels for the same user and project' do
+ subject do
+ project_auth.dup.tap do |auth|
+ auth.access_level = Gitlab::Access::MAINTAINER
+ end
+ end
+
+ it { is_expected.to be_invalid }
+
+ context 'after validation' do
+ before do
+ subject.valid?
+ end
+
+ it 'contains duplicate error' do
+ expect(subject.errors[:user]).to include('has already been taken')
+ end
+ end
+ end
+ end
+
describe 'relations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:project) }
diff --git a/spec/models/projects/triggered_hooks_spec.rb b/spec/models/projects/triggered_hooks_spec.rb
new file mode 100644
index 00000000000..3c885bdac8e
--- /dev/null
+++ b/spec/models/projects/triggered_hooks_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::TriggeredHooks do
+ let_it_be(:project) { create(:project) }
+
+ let_it_be(:universal_push_hook) { create(:project_hook, project: project, push_events: true) }
+ let_it_be(:selective_push_hook) { create(:project_hook, :with_push_branch_filter, project: project, push_events: true) }
+ let_it_be(:issues_hook) { create(:project_hook, project: project, issues_events: true, push_events: false) }
+
+ let(:wh_service) { instance_double(::WebHookService, async_execute: true) }
+
+ def run_hooks(scope, data)
+ hooks = described_class.new(scope, data)
+ hooks.add_hooks(ProjectHook.all)
+ hooks.execute
+ end
+
+ it 'executes hooks by scope' do
+ data = { some: 'data', as: 'json' }
+
+ expect_hook_execution(issues_hook, data, 'issue_hooks')
+
+ run_hooks(:issue_hooks, data)
+ end
+
+ it 'applies branch filters, when they match' do
+ data = { some: 'data', as: 'json', ref: "refs/heads/#{generate(:branch)}" }
+
+ expect_hook_execution(universal_push_hook, data, 'push_hooks')
+ expect_hook_execution(selective_push_hook, data, 'push_hooks')
+
+ run_hooks(:push_hooks, data)
+ end
+
+ it 'applies branch filters, when they do not match' do
+ data = { some: 'data', as: 'json', ref: "refs/heads/master}" }
+
+ expect_hook_execution(universal_push_hook, data, 'push_hooks')
+
+ run_hooks(:push_hooks, data)
+ end
+
+ def expect_hook_execution(hook, data, scope)
+ expect(WebHookService).to receive(:new).with(hook, data, scope).and_return(wh_service)
+ end
+end
diff --git a/spec/policies/application_setting_policy_spec.rb b/spec/policies/application_setting_policy_spec.rb
new file mode 100644
index 00000000000..f5f02d25c64
--- /dev/null
+++ b/spec/policies/application_setting_policy_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ApplicationSettingPolicy do
+ let(:current_user) { create(:user) }
+ let(:user) { create(:user) }
+
+ subject { described_class.new(current_user, [user]) }
+
+ describe 'update_runners_registration_token' do
+ context 'when anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.not_to be_allowed(:update_runners_registration_token) }
+ end
+
+ context 'regular user' do
+ it { is_expected.not_to be_allowed(:update_runners_registration_token) }
+ end
+
+ context 'when external' do
+ let(:current_user) { build(:user, :external) }
+
+ it { is_expected.not_to be_allowed(:update_runners_registration_token) }
+ end
+
+ context 'admin' do
+ let(:current_user) { create(:admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { is_expected.to be_allowed(:update_runners_registration_token) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.to be_disallowed(:update_runners_registration_token) }
+ end
+ end
+ end
+end
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index ca9a5b1853c..04d7eca6f09 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -591,34 +591,4 @@ RSpec.describe GlobalPolicy do
it { is_expected.not_to be_allowed(:log_in) }
end
end
-
- describe 'update_runners_registration_token' do
- context 'when anonymous' do
- let(:current_user) { nil }
-
- it { is_expected.not_to be_allowed(:update_runners_registration_token) }
- end
-
- context 'regular user' do
- it { is_expected.not_to be_allowed(:update_runners_registration_token) }
- end
-
- context 'when external' do
- let(:current_user) { build(:user, :external) }
-
- it { is_expected.not_to be_allowed(:update_runners_registration_token) }
- end
-
- context 'admin' do
- let(:current_user) { create(:admin) }
-
- context 'when admin mode is enabled', :enable_admin_mode do
- it { is_expected.to be_allowed(:update_runners_registration_token) }
- end
-
- context 'when admin mode is disabled' do
- it { is_expected.to be_disallowed(:update_runners_registration_token) }
- end
- end
- end
end
diff --git a/spec/requests/api/ci/runner/jobs_trace_spec.rb b/spec/requests/api/ci/runner/jobs_trace_spec.rb
index 2760e306693..d6928969beb 100644
--- a/spec/requests/api/ci/runner/jobs_trace_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_trace_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_trace_chunks do
let(:headers) { { API::Ci::Helpers::Runner::JOB_TOKEN_HEADER => job.token, 'Content-Type' => 'text/plain' } }
let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) }
- let(:update_interval) { 10.seconds.to_i }
+ let(:update_interval) { 10.seconds }
before do
initial_patch_the_trace
@@ -81,7 +81,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_trace_chunks do
end
context 'when job was not updated recently' do
- let(:update_interval) { 15.minutes.to_i }
+ let(:update_interval) { 16.minutes }
it { expect { patch_the_trace }.to change { job.updated_at } }
@@ -293,10 +293,10 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_trace_chunks do
end
end
- Timecop.travel(job.updated_at + update_interval) do
+ travel_to(job.updated_at + update_interval) do
patch api("/jobs/#{job_id}/trace"), params: content, headers: request_headers
- job.reload
end
+ job.reload
end
def initial_patch_the_trace
diff --git a/spec/requests/api/issues/put_projects_issues_spec.rb b/spec/requests/api/issues/put_projects_issues_spec.rb
index f173d80af24..6ea77cc6578 100644
--- a/spec/requests/api/issues/put_projects_issues_spec.rb
+++ b/spec/requests/api/issues/put_projects_issues_spec.rb
@@ -323,44 +323,44 @@ RSpec.describe API::Issues do
end
it 'removes all labels and touches the record' do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: '' }
end
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to eq([])
- expect(json_response['updated_at']).to be > Time.now
+ expect(json_response['updated_at']).to be > Time.current
end
it 'removes all labels and touches the record with labels param as array' do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: [''] }
end
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to eq([])
- expect(json_response['updated_at']).to be > Time.now
+ expect(json_response['updated_at']).to be > Time.current
end
it 'updates labels and touches the record' do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: 'foo,bar' }
end
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to contain_exactly('foo', 'bar')
- expect(json_response['updated_at']).to be > Time.now
+ expect(json_response['updated_at']).to be > Time.current
end
it 'updates labels and touches the record with labels param as array' do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
put api_for_user, params: { labels: %w(foo bar) }
end
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['labels']).to include 'foo'
expect(json_response['labels']).to include 'bar'
- expect(json_response['updated_at']).to be > Time.now
+ expect(json_response['updated_at']).to be > Time.current
end
it 'allows special label names' do
diff --git a/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb b/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb
index 537d1986384..691fb3f60f4 100644
--- a/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb
+++ b/spec/services/authorized_project_update/find_records_due_for_refresh_service_spec.rb
@@ -66,19 +66,6 @@ RSpec.describe AuthorizedProjectUpdate::FindRecordsDueForRefreshService do
expect(service.execute).to eq([to_be_removed, to_be_added])
end
- it 'finds duplicate entries that has to be removed' do
- [Gitlab::Access::OWNER, Gitlab::Access::REPORTER].each do |access_level|
- user.project_authorizations.create!(project: project, access_level: access_level)
- end
-
- to_be_removed = [project.id]
- to_be_added = [
- { user_id: user.id, project_id: project.id, access_level: Gitlab::Access::OWNER }
- ]
-
- expect(service.execute).to eq([to_be_removed, to_be_added])
- end
-
it 'finds entries with wrong access levels' do
user.project_authorizations
.create!(project: project, access_level: Gitlab::Access::DEVELOPER)
diff --git a/spec/services/ci/runners/reset_registration_token_service_spec.rb b/spec/services/ci/runners/reset_registration_token_service_spec.rb
new file mode 100644
index 00000000000..c4bfff51cc8
--- /dev/null
+++ b/spec/services/ci/runners/reset_registration_token_service_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Ci::Runners::ResetRegistrationTokenService, '#execute' do
+ subject { described_class.new(scope, current_user).execute }
+
+ let_it_be(:user) { build(:user) }
+ let_it_be(:admin_user) { create(:user, :admin) }
+
+ shared_examples 'a registration token reset operation' do
+ context 'without user' do
+ let(:current_user) { nil }
+
+ it 'does not reset registration token and returns nil' do
+ expect(scope).not_to receive(token_reset_method_name)
+
+ is_expected.to be_nil
+ end
+ end
+
+ context 'with unauthorized user' do
+ let(:current_user) { user }
+
+ it 'does not reset registration token and returns nil' do
+ expect(scope).not_to receive(token_reset_method_name)
+
+ is_expected.to be_nil
+ end
+ end
+
+ context 'with admin user', :enable_admin_mode do
+ let(:current_user) { admin_user }
+
+ it 'resets registration token and returns value unchanged' do
+ expect(scope).to receive(token_reset_method_name).once do
+ expect(scope).to receive(token_method_name).once.and_return("#{token_method_name} return value")
+ end
+
+ is_expected.to eq("#{token_method_name} return value")
+ end
+ end
+ end
+
+ context 'with instance scope' do
+ let_it_be(:scope) { create(:application_setting) }
+
+ before do
+ allow(ApplicationSetting).to receive(:current).and_return(scope)
+ allow(ApplicationSetting).to receive(:current_without_cache).and_return(scope)
+ end
+
+ it_behaves_like 'a registration token reset operation' do
+ let(:token_method_name) { :runners_registration_token }
+ let(:token_reset_method_name) { :reset_runners_registration_token! }
+ end
+ end
+
+ context 'with group scope' do
+ let_it_be(:scope) { create(:group) }
+
+ it_behaves_like 'a registration token reset operation' do
+ let(:token_method_name) { :runners_token }
+ let(:token_reset_method_name) { :reset_runners_token! }
+ end
+ end
+
+ context 'with project scope' do
+ let_it_be(:scope) { create(:project) }
+
+ it_behaves_like 'a registration token reset operation' do
+ let(:token_method_name) { :runners_token }
+ let(:token_reset_method_name) { :reset_runners_token! }
+ end
+ end
+end
diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb
index b8fd2455445..e6ccb2b16e7 100644
--- a/spec/services/users/refresh_authorized_projects_service_spec.rb
+++ b/spec/services/users/refresh_authorized_projects_service_spec.rb
@@ -82,31 +82,6 @@ RSpec.describe Users::RefreshAuthorizedProjectsService do
service.execute_without_lease
end
- it 'removes duplicate entries' do
- [Gitlab::Access::OWNER, Gitlab::Access::REPORTER].each do |access_level|
- user.project_authorizations.create!(project: project, access_level: access_level)
- end
-
- to_be_removed = [project.id]
-
- to_be_added = [
- { user_id: user.id, project_id: project.id, access_level: Gitlab::Access::OWNER }
- ]
- expect(service).to(
- receive(:update_authorizations)
- .with(to_be_removed, to_be_added)
- .and_call_original)
-
- service.execute_without_lease
-
- expect(user.project_authorizations.count).to eq(1)
- project_authorization = ProjectAuthorization.where(
- project_id: project.id,
- user_id: user.id,
- access_level: Gitlab::Access::OWNER)
- expect(project_authorization).to exist
- end
-
it 'sets the access level of a project to the highest available level' do
user.project_authorizations.delete_all
diff --git a/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb b/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
index bfb719fd840..f5aa4178ae6 100644
--- a/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
+++ b/spec/support/shared_contexts/cache_allowed_users_in_namespace_shared_context.rb
@@ -10,7 +10,7 @@ RSpec.shared_examples 'allowed user IDs are cached' do
end
it 'caches the allowed user IDs in L1 cache for 1 minute', :use_clean_rails_memory_store_caching do
- Timecop.travel 2.minutes do
+ travel_to 2.minutes.from_now do
expect do
expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
@@ -20,7 +20,7 @@ RSpec.shared_examples 'allowed user IDs are cached' do
end
it 'caches the allowed user IDs in L2 cache for 5 minutes', :use_clean_rails_memory_store_caching do
- Timecop.travel 6.minutes do
+ travel_to 6.minutes.from_now do
expect do
expect(described_class.l1_cache_backend).to receive(:fetch).and_call_original
expect(described_class.l2_cache_backend).to receive(:fetch).and_call_original
diff --git a/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb b/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
index 104e91add8b..381583ff2a9 100644
--- a/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/time_tracking_shared_examples.rb
@@ -86,7 +86,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
end
it "add spent time for #{issuable_name}" do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
expect do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), params: { duration: '2h' }
end.to change { issuable.reload.updated_at }
@@ -98,7 +98,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
context 'when subtracting time' do
it 'subtracts time of the total spent time' do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
expect do
issuable.update!(spend_time: { duration: 7200, user_id: user.id })
end.to change { issuable.reload.updated_at }
@@ -115,7 +115,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
it 'does not modify the total time spent' do
issuable.update!(spend_time: { duration: 7200, user_id: user.id })
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
expect do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), params: { duration: '-1w' }
end.not_to change { issuable.reload.updated_at }
@@ -160,7 +160,7 @@ RSpec.shared_examples 'time tracking endpoints' do |issuable_name|
end
it "resets spent time for #{issuable_name}" do
- Timecop.travel(1.minute.from_now) do
+ travel_to(2.minutes.from_now) do
expect do
post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_spent_time", user)
end.to change { issuable.reload.updated_at }
diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
index d6e96ef37d6..d9105981b4b 100644
--- a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
+++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
@@ -30,18 +30,11 @@ end
# `job_args` to be arguments to #perform if it takes arguments
RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_duration|
before do
- # Allow Timecop freeze and travel without the block form
- Timecop.safe_mode = false
- Timecop.freeze
+ freeze_time
time_travel_during_perform(actual_duration)
end
- after do
- Timecop.return
- Timecop.safe_mode = true
- end
-
let(:subject_perform) { defined?(job_args) ? subject.perform(job_args) : subject.perform }
context 'when the work finishes in 0 seconds' do
@@ -58,7 +51,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat
let(:actual_duration) { 0.1 * minimum_duration }
it 'sleeps 90% of minimum duration' do
- expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.9 * minimum_duration))
+ expect(subject).to receive(:sleep).with(a_value_within(1).of(0.9 * minimum_duration))
subject_perform
end
@@ -68,7 +61,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat
let(:actual_duration) { 0.9 * minimum_duration }
it 'sleeps 10% of minimum duration' do
- expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.1 * minimum_duration))
+ expect(subject).to receive(:sleep).with(a_value_within(1).of(0.1 * minimum_duration))
subject_perform
end
@@ -111,7 +104,7 @@ RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_durat
allow(subject).to receive(:ensure_minimum_duration) do |minimum_duration, &block|
original_ensure_minimum_duration.call(minimum_duration) do
# Time travel inside the block inside ensure_minimum_duration
- Timecop.travel(actual_duration) if actual_duration && actual_duration > 0
+ travel_to(actual_duration.from_now) if actual_duration && actual_duration > 0
end
end
end