Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-03-14 18:07:46 +00:00
parent 739467f1fa
commit fbf2955cfc
74 changed files with 1305 additions and 401 deletions

View File

@ -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

View File

@ -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>

View File

@ -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"

View File

@ -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'),

View File

@ -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) {

View File

@ -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);
})

View File

@ -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);
}

View File

@ -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 };
},
};
};

View File

@ -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"
>

View File

@ -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

View File

@ -2,6 +2,7 @@
class Admin::ClustersController < Clusters::ClustersController
include EnforcesAdminAuthentication
before_action :ensure_feature_enabled!
layout 'admin'

View File

@ -3,6 +3,7 @@
class Groups::ClustersController < Clusters::ClustersController
include ControllerWithCrossProjectAccessCheck
before_action :ensure_feature_enabled!
prepend_before_action :group
requires_cross_project_access

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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')

View File

@ -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

View File

@ -0,0 +1 @@
0af6e6e56967cef9d1160dbfd95456428337843d893307c69505e1a2d3c2074a

View File

@ -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);

View File

@ -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) |

View File

@ -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.

90
doc/api/linked_epics.md Normal file
View File

@ -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"
}
]
```

View File

@ -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`

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'),

View File

@ -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 ""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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({});
});
});
});

View File

@ -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());
});
});

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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