Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
739467f1fa
commit
fbf2955cfc
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Admin::ClustersController < Clusters::ClustersController
|
||||
include EnforcesAdminAuthentication
|
||||
before_action :ensure_feature_enabled!
|
||||
|
||||
layout 'admin'
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
class Groups::ClustersController < Clusters::ClustersController
|
||||
include ControllerWithCrossProjectAccessCheck
|
||||
|
||||
before_action :ensure_feature_enabled!
|
||||
prepend_before_action :group
|
||||
requires_cross_project_access
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
0af6e6e56967cef9d1160dbfd95456428337843d893307c69505e1a2d3c2074a
|
|
@ -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);
|
||||
|
|
|
@ -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) |
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
```
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
describe('when user cannot add clusters', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ canAddCluster: false });
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(binding.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders correct modal id for the agent link', () => {
|
||||
const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
|
||||
|
||||
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
|
||||
});
|
||||
|
||||
it('shows tooltip', () => {
|
||||
const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
|
||||
expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
|
||||
});
|
||||
|
||||
it('shows split button in dropdown', () => {
|
||||
expect(findDropdown().props('split')).toBe(true);
|
||||
});
|
||||
|
||||
it('binds split button with modal id', () => {
|
||||
const binding = getBinding(findDropdown().element, 'gl-modal-directive');
|
||||
|
||||
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when on group or admin level', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ displayClusterAgents: false });
|
||||
});
|
||||
|
||||
it('renders a dropdown with 2 actions items', () => {
|
||||
expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']);
|
||||
});
|
||||
|
||||
it('shows tooltip', () => {
|
||||
const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
|
||||
expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(binding.value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
describe('when the certificate based clusters not enabled', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ canAddCluster: false });
|
||||
createWrapper({ certificateBasedClustersEnabled: false });
|
||||
});
|
||||
|
||||
it('disables dropdown', () => {
|
||||
expect(findDropdown().props('disabled')).toBe(true);
|
||||
it('it does not show the the dropdown', () => {
|
||||
expect(findDropdown().exists()).toBe(false);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(binding.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders correct modal id for the agent link', () => {
|
||||
const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
|
||||
|
||||
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
|
||||
});
|
||||
|
||||
it('shows tooltip', () => {
|
||||
const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
|
||||
expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
|
||||
});
|
||||
|
||||
it('shows split button in dropdown', () => {
|
||||
expect(findDropdown().props('split')).toBe(true);
|
||||
});
|
||||
|
||||
it('binds split button with modal id', () => {
|
||||
const binding = getBinding(findDropdown().element, 'gl-modal-directive');
|
||||
|
||||
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when on group or admin level', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ displayClusterAgents: false });
|
||||
});
|
||||
|
||||
it('renders a dropdown with 2 actions items', () => {
|
||||
expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']);
|
||||
});
|
||||
|
||||
it('shows tooltip', () => {
|
||||
const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
|
||||
expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when on group or admin level', () => {
|
||||
beforeEach(() => {
|
||||
findTabs().vm.$emit('input', tab);
|
||||
createWrapper({ displayClusterAgents: false });
|
||||
});
|
||||
|
||||
it('passes child-component param to the component', () => {
|
||||
expect(findComponent().props('defaultBranchName')).toBe(defaultBranchName);
|
||||
it('renders correct number of tabs', () => {
|
||||
expect(findAllTabs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it(`sets max-agents param to ${maxAgents} and passes it to the modal`, () => {
|
||||
expect(findModal().props('maxAgents')).toBe(maxAgents);
|
||||
it('renders correct tab title', () => {
|
||||
expect(findGlTabAtIndex(0).attributes('title')).toBe(CERTIFICATE_TAB.title);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the certificate based clusters not enabled', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ certificateBasedClustersEnabled: false });
|
||||
});
|
||||
|
||||
it(`sends the correct tracking event with the property '${tabName}'`, () => {
|
||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, {
|
||||
label: EVENT_LABEL_TABS,
|
||||
property: tabName,
|
||||
it('it displays only the Agent tab', () => {
|
||||
expect(findAllTabs()).toHaveLength(1);
|
||||
const agentTab = findGlTabAtIndex(0);
|
||||
|
||||
expect(agentTab.props()).toMatchObject({
|
||||
queryParamValue: AGENT_TAB.queryParamValue,
|
||||
titleLinkClass: '',
|
||||
});
|
||||
expect(agentTab.attributes()).toMatchObject({
|
||||
title: AGENT_TAB.title,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when on group or admin level', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ displayClusterAgents: false });
|
||||
});
|
||||
|
||||
it('renders correct number of tabs', () => {
|
||||
expect(findAllTabs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders correct tab title', () => {
|
||||
expect(findGlTabAtIndex(0).attributes('title')).toBe(CERTIFICATE_TAB.title);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 });
|
||||
|
||||
const result = await deserializer.deserialize({
|
||||
content: 'content',
|
||||
schema: tiptapEditor.schema,
|
||||
renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p>`);
|
||||
|
||||
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.document.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
});
|
||||
|
||||
expect(result.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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue