diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-14 21:07:46 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-14 21:07:46 +0300 |
commit | fbf2955cfc9ffc319d57960a0b0df1ee1b5fd05f (patch) | |
tree | 6964ec0aaac3d432a4795878e87d78566f7bf719 | |
parent | 739467f1fa4d5d4042b47ff6637a567d1ad6a4a4 (diff) |
Add latest changes from gitlab-org/gitlab@master
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 |