Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-02-22 15:07:57 +00:00
parent fb336d5f6b
commit 68aa32736b
76 changed files with 1118 additions and 436 deletions

View File

@ -26,12 +26,10 @@ export default function initPageShortcuts() {
// the pages above have their own shortcuts sub-classes instantiated elsewhere
// TODO: replace this whitelist with something more automated/maintainable
// https://gitlab.com/gitlab-org/gitlab/-/issues/392845
if (page && !pagesWithCustomShortcuts.includes(page)) {
import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts')
.then(({ default: Shortcuts }) => {
const shortcuts = new Shortcuts();
window.toggleShortcutsHelp = shortcuts.onToggleHelp;
})
.then(({ default: Shortcuts }) => new Shortcuts())
.catch(() => {});
}
return false;

View File

@ -124,8 +124,11 @@ export default class Shortcuts {
e.preventDefault();
});
const shortcutsModalTriggerEvent = 'click.shortcutsModalTrigger';
// eslint-disable-next-line @gitlab/no-global-event-off
$('.js-shortcuts-modal-trigger').off('click').on('click', this.onToggleHelp);
$(document)
.off(shortcutsModalTriggerEvent)
.on(shortcutsModalTriggerEvent, '.js-shortcuts-modal-trigger', this.onToggleHelp);
if (shouldDisableShortcuts()) {
disableShortcuts();

View File

@ -32,6 +32,7 @@ export const integrationFormSections = {
JIRA_TRIGGER: 'jira_trigger',
JIRA_ISSUES: 'jira_issues',
TRIGGER: 'trigger',
APPLE_APP_STORE: 'apple_app_store',
};
export const integrationFormSectionComponents = {
@ -40,6 +41,7 @@ export const integrationFormSectionComponents = {
[integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
[integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
[integrationFormSections.TRIGGER]: 'IntegrationSectionTrigger',
[integrationFormSections.APPLE_APP_STORE]: 'IntegrationSectionAppleAppStore',
};
export const integrationTriggerEvents = {

View File

@ -59,9 +59,6 @@ export default {
return this.propsSource.editable;
},
hasSections() {
if (this.hasSlackNotificationsDisabled) {
return false;
}
return this.customState.sections.length !== 0;
},
fieldsWithoutSection() {
@ -70,17 +67,11 @@ export default {
: this.propsSource.fields;
},
hasFieldsWithoutSection() {
if (this.hasSlackNotificationsDisabled) {
return false;
}
return this.fieldsWithoutSection.length;
},
isSlackIntegration() {
return this.propsSource.type === INTEGRATION_FORM_TYPE_SLACK;
},
hasSlackNotificationsDisabled() {
return this.isSlackIntegration && !this.glFeatures.integrationSlackAppNotifications;
},
showHelpHtml() {
if (this.isSlackIntegration) {
return this.helpHtml;
@ -90,7 +81,6 @@ export default {
shouldUpgradeSlack() {
return (
this.isSlackIntegration &&
this.glFeatures.integrationSlackAppNotifications &&
this.customState.shouldUpgradeSlack &&
(this.hasFieldsWithoutSection || this.hasSections)
);

View File

@ -28,6 +28,10 @@ export default {
import(
/* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
),
IntegrationSectionAppleAppStore: () =>
import(
/* webpackChunkName: 'IntegrationSectionAppleAppStore' */ '~/integrations/edit/components/sections/apple_app_store.vue'
),
},
directives: {
SafeHtml,

View File

@ -0,0 +1,73 @@
<script>
import { mapGetters } from 'vuex';
import { sprintf, s__ } from '~/locale';
import UploadDropzoneField from '../upload_dropzone_field.vue';
import Connection from './connection.vue';
export default {
name: 'IntegrationSectionAppleAppStore',
components: {
Connection,
UploadDropzoneField,
},
data() {
return {
dropzoneAllowList: ['.p8'],
};
},
i18n: {
dropzoneDescription: s__(
'AppleAppStore|Drag your Private Key file here or %{linkStart}click to upload%{linkEnd}.',
),
dropzoneErrorMessage: s__(
'AppleAppStore|Error: You are trying to upload something other than a Private Key file.',
),
dropzoneConfirmMessage: s__('AppleAppStore|Drop your Private Key file to start the upload.'),
dropzoneEmptyInputName: s__('AppleAppStore|The Apple App Store Connect Private Key (.p8)'),
dropzoneNonEmptyInputName: s__(
'AppleAppStore|Upload a new Apple App Store Connect Private Key (replace %{currentFileName})',
),
dropzoneNonEmptyInputHelp: s__('AppleAppStore|Leave empty to use your current Private Key.'),
},
computed: {
...mapGetters(['propsSource']),
dynamicFields() {
return this.propsSource.fields.filter(
(field) => field.name !== 'app_store_private_key_file_name',
);
},
fileNameField() {
return this.propsSource.fields.find(
(field) => field.name === 'app_store_private_key_file_name',
);
},
dropzoneLabel() {
return this.fileNameField.value
? sprintf(this.$options.i18n.dropzoneNonEmptyInputName, {
currentFileName: this.fileNameField.value,
})
: this.$options.i18n.dropzoneEmptyInputName;
},
dropzoneHelpText() {
return this.fileNameField.value ? this.$options.i18n.dropzoneNonEmptyInputHelp : '';
},
},
};
</script>
<template>
<span>
<connection :fields="dynamicFields" />
<upload-dropzone-field
name="service[app_store_private_key]"
:label="dropzoneLabel"
:help-text="dropzoneHelpText"
file-input-name="service[app_store_private_key_file_name]"
:allow-list="dropzoneAllowList"
:description="$options.i18n.dropzoneDescription"
:error-message="$options.i18n.dropzoneErrorMessage"
:confirm-message="$options.i18n.dropzoneConfirmMessage"
/>
</span>
</template>

View File

@ -0,0 +1,143 @@
<script>
import { GlLink, GlSprintf, GlAlert, GlFormGroup } from '@gitlab/ui';
import { validateFileFromAllowList } from '~/lib/utils/file_upload';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import { s__ } from '~/locale';
const i18n = Object.freeze({
description: s__('Integrations|Drag your file here or %{linkStart}click to upload%{linkEnd}.'),
errorMessage: s__(
'Integrations|Error: You are trying to upload something other than an allowed file.',
),
confirmMessage: s__('Integrations|Drop your file to start the upload.'),
});
export default {
name: 'UploadDropzoneField',
components: {
UploadDropzone,
GlLink,
GlSprintf,
GlAlert,
GlFormGroup,
},
i18n,
props: {
name: {
type: String,
required: true,
default: null,
},
label: {
type: String,
required: true,
default: null,
},
helpText: {
type: String,
required: false,
default: null,
},
fileInputName: {
type: String,
required: true,
default: null,
},
allowList: {
type: Array,
required: false,
default: null,
},
description: {
type: String,
required: false,
default: i18n.description,
},
errorMessage: {
type: String,
required: false,
default: i18n.errorMessage,
},
confirmMessage: {
type: String,
required: false,
default: i18n.confirmMessage,
},
},
data() {
return {
fileName: null,
fileContents: null,
uploadError: false,
inputDisabled: true,
};
},
computed: {
dropzoneDescription() {
return this.fileName ?? this.description;
},
},
methods: {
clearError() {
this.uploadError = false;
},
onChange(file) {
this.clearError();
this.inputDisabled = false;
this.fileName = file?.name;
this.readFile(file);
},
isValidFileType(file) {
return validateFileFromAllowList(file.name, this.allowList);
},
onError() {
this.uploadError = this.errorMessage;
},
readFile(file) {
const reader = new FileReader();
reader.readAsText(file);
reader.onload = (evt) => {
this.fileContents = evt.target.result;
};
},
},
};
</script>
<template>
<gl-form-group :label="label" :label-for="name">
<upload-dropzone
input-field-name="service[dropzone_file_name]"
:is-file-valid="isValidFileType"
:valid-file-mimetypes="allowList"
:should-update-input-on-file-drop="true"
:single-file-selection="true"
:enable-drag-behavior="false"
:drop-to-start-message="confirmMessage"
@change="onChange"
@error="onError"
>
<template #upload-text="{ openFileUpload }">
<gl-sprintf :message="dropzoneDescription">
<template #link="{ content }">
<gl-link @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
<template #invalid-drag-data-slot>
{{ errorMessage }}
</template>
</upload-dropzone>
<gl-alert v-if="uploadError" variant="danger" :dismissible="true" @dismiss="clearError">
{{ uploadError }}
</gl-alert>
<input :name="name" type="hidden" :disabled="inputDisabled" :value="fileContents || false" />
<input
:name="fileInputName"
type="hidden"
:disabled="inputDisabled"
:value="fileName || false"
/>
<span>{{ helpText }}</span>
</gl-form-group>
</template>

View File

@ -2,6 +2,7 @@
import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { isEmpty } from 'lodash';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
@ -206,9 +207,8 @@ export default {
Sentry.captureException(error);
},
skip() {
return !this.hasAnyIssues;
return !this.hasAnyIssues || isEmpty(this.pageParams);
},
debounce: 200,
},
issuesCounts: {
query: getIssuesCountsQuery,
@ -223,9 +223,8 @@ export default {
Sentry.captureException(error);
},
skip() {
return !this.hasAnyIssues;
return !this.hasAnyIssues || isEmpty(this.pageParams);
},
debounce: 200,
context: {
isSingleRequest: true,
},

View File

@ -29,3 +29,10 @@ export const validateImageName = (file) => {
const legalImageRegex = /^[\w.\-+]+\.(png|jpg|jpeg|gif|bmp|tiff|ico|webp)$/;
return legalImageRegex.test(fileName) ? fileName : 'image.png';
};
export const validateFileFromAllowList = (fileName, allowList) => {
const parts = fileName.split('.');
const ext = `.${parts[parts.length - 1]}`;
return allowList.includes(ext);
};

View File

@ -68,6 +68,9 @@ export default {
{
text: this.$options.i18n.shortcuts,
action: this.showKeyboardShortcuts,
extraAttrs: {
class: 'js-shortcuts-modal-trigger',
},
shortcut: '?',
},
this.sidebarData.display_whats_new && {
@ -98,7 +101,6 @@ export default {
showKeyboardShortcuts() {
this.$refs.dropdown.close();
window?.toggleShortcutsHelp();
},
async showWhatsNew() {

View File

@ -215,6 +215,9 @@ export default {
workItemType() {
return this.workItem.workItemType?.name;
},
workItemBreadcrumbReference() {
return this.workItemType ? `${this.workItemType} #${this.workItem.iid}` : '';
},
canUpdate() {
return this.workItem?.userPermissions?.updateWorkItem;
},
@ -245,6 +248,9 @@ export default {
parentWorkItemConfidentiality() {
return this.parentWorkItem?.confidential;
},
parentWorkItemReference() {
return this.parentWorkItem ? `${this.parentWorkItem.title} #${this.parentWorkItem.iid}` : '';
},
parentUrl() {
// Once more types are moved to have Work Items involved
// we need to handle this properly.
@ -510,9 +516,9 @@ export default {
:icon="parentWorkItemIconName"
category="tertiary"
:href="parentUrl"
:title="parentWorkItem.title"
:title="parentWorkItemReference"
@click="openInModal($event, parentWorkItem)"
>{{ parentWorkItem.title }}</gl-button
>{{ parentWorkItemReference }}</gl-button
>
<gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
</li>
@ -523,7 +529,7 @@ export default {
:work-item-icon-name="workItemIconName"
:work-item-type="workItemType && workItemType.toUpperCase()"
/>
{{ workItemType }}
{{ workItemBreadcrumbReference }}
</li>
</ul>
<work-item-type-icon

View File

@ -270,7 +270,8 @@ $tabs-holder-z-index: 250;
position: -webkit-sticky;
position: sticky;
top: calc(var(--top-pos) + var(--performance-bar-height, 0px));
max-height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
min-height: 300px;
height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
.drag-handle {
bottom: 16px;

View File

@ -3,7 +3,7 @@
class Admin::ApplicationsController < Admin::ApplicationController
include OauthApplications
before_action :set_application, only: [:show, :edit, :update, :destroy]
before_action :set_application, only: [:show, :edit, :update, :renew, :destroy]
before_action :load_scopes, only: [:new, :create, :edit, :update]
feature_category :authentication_and_authorization
@ -51,6 +51,17 @@ class Admin::ApplicationsController < Admin::ApplicationController
end
end
def renew
@application.renew_secret
if @application.save
flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.')
render :show
else
redirect_to admin_application_url(@application)
end
end
def destroy
@application.destroy
redirect_to admin_applications_url, status: :found, notice: _('Application was successfully destroyed.')

View File

@ -8,6 +8,7 @@ module Integrations
:app_store_issuer_id,
:app_store_key_id,
:app_store_private_key,
:app_store_private_key_file_name,
:active,
:alert_events,
:api_key,

View File

@ -6,7 +6,7 @@ module Groups
include OauthApplications
prepend_before_action :authorize_admin_group!
before_action :set_application, only: [:show, :edit, :update, :destroy]
before_action :set_application, only: [:show, :edit, :update, :renew, :destroy]
before_action :load_scopes, only: [:index, :create, :edit, :update]
feature_category :authentication_and_authorization
@ -51,6 +51,17 @@ module Groups
end
end
def renew
@application.renew_secret
if @application.save
flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.')
render :show
else
redirect_to group_settings_application_url(@group, @application)
end
end
def destroy
@application.destroy
redirect_to group_settings_applications_url(@group), status: :found, notice: _('Application was successfully destroyed.')

View File

@ -47,6 +47,19 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
end
end
def renew
set_application
@application.renew_secret
if @application.save
flash.now[:notice] = s_('AuthorizedApplication|Application secret was successfully updated.')
render :show
else
redirect_to oauth_application_url(@application)
end
end
private
def verify_user_oauth_applications_enabled

View File

@ -40,9 +40,9 @@ module Mutations
result = ::Clusters::AgentTokens::CreateService
.new(
container: cluster_agent.project,
agent: cluster_agent,
current_user: current_user,
params: args.merge(agent_id: cluster_agent.id)
params: args
)
.execute

View File

@ -16,7 +16,8 @@ module Mutations
def resolve(id:)
token = authorized_find!(id: id)
token.update(status: token.class.statuses[:revoked])
::Clusters::AgentTokens::RevokeService.new(token: token, current_user: current_user).execute
{ errors: errors_on_object(token) }
end

View File

@ -68,9 +68,8 @@ module Ci
end
class_methods do
def partitionable(scope:, through: nil, partitioned: false)
def partitionable(scope:, through: nil)
handle_partitionable_through(through)
handle_partitionable_dml(partitioned)
handle_partitionable_scope(scope)
end
@ -85,13 +84,6 @@ module Ci
include Partitionable::Switch
end
def handle_partitionable_dml(partitioned)
define_singleton_method(:partitioned?) { partitioned }
return unless partitioned
include Partitionable::PartitionedFilter
end
def handle_partitionable_scope(scope)
define_method(:partition_scope_value) do
strong_memoize(:partition_scope_value) do

View File

@ -1,41 +0,0 @@
# frozen_string_literal: true
module Ci
module Partitionable
# Used to patch the save, update, delete, destroy methods to use the
# partition_id attributes for their SQL queries.
module PartitionedFilter
extend ActiveSupport::Concern
if Rails::VERSION::MAJOR >= 7
# These methods are updated in Rails 7 to use `_primary_key_constraints_hash`
# by default, so this patch will no longer be required.
#
# rubocop:disable Gitlab/NoCodeCoverageComment
# :nocov:
raise "`#{__FILE__}` should be double checked" if Rails.env.test?
warn "Update `#{__FILE__}`. Patches Rails internals for partitioning"
# :nocov:
# rubocop:enable Gitlab/NoCodeCoverageComment
else
def _update_row(attribute_names, attempted_action = "update")
self.class._update_record(
attributes_with_values(attribute_names),
_primary_key_constraints_hash
)
end
def _delete_row
self.class._delete_record(_primary_key_constraints_hash)
end
end
# Introduced in Rails 7, but updated to include `partition_id` filter.
# https://github.com/rails/rails/blob/a4dbb153fd390ac31bb9808809e7ac4d3a2c5116/activerecord/lib/active_record/persistence.rb#L1031-L1033
def _primary_key_constraints_hash
{ @primary_key => id_in_database, partition_id: partition_id } # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
end
end
end

View File

@ -7,10 +7,13 @@ module Integrations
ISSUER_ID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/.freeze
KEY_ID_REGEX = /\A(?=.*[A-Z])(?=.*[0-9])[A-Z0-9]+\z/.freeze
SECTION_TYPE_APPLE_APP_STORE = 'apple_app_store'
with_options if: :activated? do
validates :app_store_issuer_id, presence: true, format: { with: ISSUER_ID_REGEX }
validates :app_store_key_id, presence: true, format: { with: KEY_ID_REGEX }
validates :app_store_private_key, presence: true, certificate_key: true
validates :app_store_private_key_file_name, presence: true
end
field :app_store_issuer_id,
@ -24,13 +27,12 @@ module Integrations
title: -> { s_('AppleAppStore|The Apple App Store Connect Key ID.') },
is_secret: false
field :app_store_private_key,
field :app_store_private_key_file_name,
section: SECTION_TYPE_CONNECTION,
required: true,
type: 'textarea',
title: -> { s_('AppleAppStore|The Apple App Store Connect Private Key.') },
is_secret: false
field :app_store_private_key, api_only: true, is_secret: false
def title
'Apple App Store Connect'
end
@ -69,7 +71,7 @@ module Integrations
def sections
[
{
type: SECTION_TYPE_CONNECTION,
type: SECTION_TYPE_APPLE_APP_STORE,
title: s_('Integrations|Integration details'),
description: help
}
@ -99,13 +101,11 @@ module Integrations
private
def client
config = {
AppStoreConnect::Client.new(
issuer_id: app_store_issuer_id,
key_id: app_store_key_id,
private_key: app_store_private_key
}
AppStoreConnect::Client.new(config)
)
end
end
end

View File

@ -2,13 +2,21 @@
module Clusters
module AgentTokens
class CreateService < ::BaseContainerService
class CreateService
ALLOWED_PARAMS = %i[agent_id description name].freeze
def execute
return error_no_permissions unless current_user.can?(:create_cluster, container)
attr_reader :agent, :current_user, :params
token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
def initialize(agent:, current_user:, params:)
@agent = agent
@current_user = current_user
@params = params
end
def execute
return error_no_permissions unless current_user.can?(:create_cluster, agent.project)
token = ::Clusters::AgentToken.new(filtered_params.merge(agent_id: agent.id, created_by_user: current_user))
if token.save
log_activity_event!(token)
@ -42,3 +50,5 @@ module Clusters
end
end
end
Clusters::AgentTokens::CreateService.prepend_mod

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Clusters
module AgentTokens
class RevokeService
attr_reader :current_project, :current_user, :token
def initialize(token:, current_user:)
@token = token
@current_user = current_user
end
def execute
return error_no_permissions unless current_user.can?(:create_cluster, token.agent.project)
if token.update(status: token.class.statuses[:revoked])
ServiceResponse.success
else
ServiceResponse.error(message: token.errors.full_messages)
end
end
private
def error_no_permissions
ServiceResponse.error(
message: s_('ClusterAgent|User has insufficient permissions to revoke the token for this project'))
end
end
end
end
Clusters::AgentTokens::RevokeService.prepend_mod

View File

@ -7,4 +7,5 @@
edit_path: edit_admin_application_path(@application),
delete_path: admin_application_path(@application),
index_path: admin_applications_path,
renew_path: renew_admin_application_path(@application),
show_trusted_row: true

View File

@ -9,4 +9,5 @@
= render 'shared/doorkeeper/applications/show',
edit_path: edit_oauth_application_path(@application),
delete_path: oauth_application_path(@application),
index_path: oauth_applications_path
index_path: oauth_applications_path,
renew_path: renew_oauth_application_path(@application)

View File

@ -9,4 +9,5 @@
= render 'shared/doorkeeper/applications/show',
edit_path: edit_group_settings_application_path(@group, @application),
delete_path: group_settings_application_path(@group, @application),
index_path: group_settings_applications_path
index_path: group_settings_applications_path,
renew_path: renew_group_settings_application_path(@group, @application)

View File

@ -17,12 +17,17 @@
%td
- if Feature.enabled?('hash_oauth_secrets')
- if @application.plaintext_secret
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5'}) do |c|
= c.body do
= _('This is the only time the secret is accessible. Copy the secret and store it securely.')
= clipboard_button(clipboard_text: @application.plaintext_secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
%span= _('This is the only time the secret is accessible. Copy the secret and store it securely.')
- else
= _('The secret is only available when you first create the application.')
= render Pajamas::AlertComponent.new(variant: :warning, dismissible: false, alert_options: { class: 'gl-mb-5'}) do |c|
= c.body do
= _('The secret is only available when you create the application or renew the secret.')
- else
= clipboard_button(clipboard_text: @application.secret, button_text: _('Copy'), title: _("Copy secret"), class: "btn btn-default btn-md gl-button")
= render 'shared/doorkeeper/applications/update_form', path: renew_path
%tr
%td
= _('Callback URL')

View File

@ -0,0 +1,3 @@
- path = local_assigns.fetch(:path)
= form_for(@application, url: path, html: {class: 'gl-display-inline-block', method: "put"}) do |f|
= submit_tag s_('AuthorizedApplication|Renew secret'), data: { confirm: s_("AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab."), confirm_btn_variant: "danger" }, aria: { label: s_('AuthorizedApplication|Renew secret') }, class: 'gl-button btn btn-md btn-default'

View File

@ -1,4 +1,4 @@
- if Gitlab.com? && Feature.enabled?(:integration_slack_app_notifications)
- if Gitlab.com?
= render Pajamas::AlertComponent.new(title: _('Slack notifications integration is deprecated'),
variant: :warning,
dismissible: false,

View File

@ -1,8 +0,0 @@
---
name: integration_slack_app_notifications
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98663
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/381012
milestone: '15.5'
type: development
group: group::integrations
default_enabled: false

View File

@ -29,6 +29,7 @@ InitializerConnections.raise_if_new_database_connection do
token_info: 'oauth/token_info',
tokens: 'oauth/tokens'
end
put '/oauth/applications/:id/renew(.:format)' => 'oauth/applications#renew', as: :renew_oauth_application
# This prefixless path is required because Jira gets confused if we set it up with a path
# More information: https://gitlab.com/gitlab-org/gitlab/issues/6752

View File

@ -44,7 +44,9 @@ namespace :admin do
end
end
resources :applications
resources :applications do
put 'renew', on: :member
end
resources :groups, only: [:index, :new, :create]

View File

@ -56,7 +56,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end
end
resources :applications
resources :applications do
put 'renew', on: :member
end
resource :packages_and_registries, only: [:show]
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class AddFkToPCiBuildsMetadataPartitionsOnPartitionIdAndBuildId < Gitlab::Database::Migration[2.1]
disable_ddl_transaction!
SOURCE_TABLE_NAME = :p_ci_builds_metadata
TARGET_TABLE_NAME = :ci_builds
COLUMN = :build_id
TARGET_COLUMN = :id
FK_NAME = :fk_e20479742e_p
PARTITION_COLUMN = :partition_id
def up
Gitlab::Database::PostgresPartitionedTable.each_partition(SOURCE_TABLE_NAME) do |partition|
add_concurrent_foreign_key(
partition.identifier,
TARGET_TABLE_NAME,
column: [PARTITION_COLUMN, COLUMN],
target_column: [PARTITION_COLUMN, TARGET_COLUMN],
validate: false,
reverse_lock_order: true,
on_update: :cascade,
on_delete: :cascade,
name: FK_NAME
)
end
end
def down
Gitlab::Database::PostgresPartitionedTable.each_partition(SOURCE_TABLE_NAME) do |partition|
with_lock_retries do
remove_foreign_key_if_exists(
partition.identifier,
TARGET_TABLE_NAME,
name: FK_NAME,
reverse_lock_order: true
)
end
end
end
end

View File

@ -0,0 +1 @@
f64a3cb1963dde07eaaae9d331ebf1e5e52050435b38f9b6727a53f04808b723

View File

@ -34701,6 +34701,9 @@ ALTER TABLE ONLY ci_sources_pipelines
ALTER TABLE p_ci_builds_metadata
ADD CONSTRAINT fk_e20479742e FOREIGN KEY (build_id) REFERENCES ci_builds(id) ON DELETE CASCADE;
ALTER TABLE ONLY ci_builds_metadata
ADD CONSTRAINT fk_e20479742e_p FOREIGN KEY (partition_id, build_id) REFERENCES ci_builds(partition_id, id) ON UPDATE CASCADE ON DELETE CASCADE NOT VALID;
ALTER TABLE ONLY gitlab_subscriptions
ADD CONSTRAINT fk_e2595d00a1 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;

View File

@ -314,6 +314,15 @@ The following actions on projects generate project audit events:
- An environment is protected or unprotected.
[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216164) in GitLab 15.8.
### GitLab agent for Kubernetes events
The following actions on projects generate agent audit events:
- A cluster agent token is created.
Introduced in GitLab 15.9
- A cluster agent token is revoked.
Introduced in GitLab 15.9
### Instance events **(PREMIUM SELF)**
The following user actions on a GitLab instance generate instance audit events:

View File

@ -33,6 +33,7 @@ and all **secondary** sites:
1. SSH into each node of the **primary** site.
1. [Upgrade GitLab on the **primary** site](../../../update/package/index.md#upgrade-using-the-official-repositories).
1. Perform testing on the **primary** site, particularly if you paused replication in step 1 to protect DR. [There are some suggestions for post-upgrade testing](../../../update/plan_your_upgrade.md#pre-upgrade-and-post-upgrade-checks) in the upgrade documentation.
1. Ensure that the secrets in the `/etc/gitlab/gitlab-secrets.json` file of both the primary site and the secondary site are the same. The file must be the same on all of a sites nodes.
1. SSH into each node of **secondary** sites.
1. [Upgrade GitLab on each **secondary** site](../../../update/package/index.md#upgrade-using-the-official-repositories).
1. If you paused replication in step 1, [resume replication on each **secondary**](../index.md#pausing-and-resuming-replication).

View File

@ -225,9 +225,8 @@ even when not releasing versions in the catalog.
The version of the component can be (in order of highest priority first):
1. A commit SHA - For example: `gitlab.com/gitlab-org/dast@e3262fdd0914fa823210cdb79a8c421e2cef79d8`
1. A released tag - For example: `gitlab.com/gitlab-org/dast@1.0`
1. A tag - For example: `gitlab.com/gitlab-org/dast@1.0`
1. A special moving target version that points to the most recent released tag - For example: `gitlab.com/gitlab-org/dast@~latest`
1. An unreleased tag - For example: `gitlab.com/gitlab-org/dast@rc-1.0`
1. A branch name - For example: `gitlab.com/gitlab-org/dast@master`
If a tag and branch exist with the same name, the tag takes precedence over the branch.
@ -236,10 +235,15 @@ takes precedence over the tag.
As we want to be able to reference any revisions (even those not released), a component must be defined in a Git repository.
NOTE:
When referencing a component by local path (for example `./path/to/component`), its version is implicit and matches
the commit SHA of the current pipeline context.
Only released tags are displayed in the catalog for a given project, because creating a release is an official act of versioning.
Users can still use branch names, unreleased tags, or commit SHAs to include a component in their CI configuration, but the recommended way is to use the releases.
NOTE:
The use of `@~latest` returns the latest release (if any). It does not include any unreleased tags.
## Components project
A components project is a GitLab project/repository that exclusively hosts one or more pipeline components.

View File

@ -20,19 +20,82 @@ It's the offering of choice for enterprises and organizations in highly regulate
## Available features
- Authentication: Support for instance-level [SAML OmniAuth](../../integration/saml.md) functionality. GitLab Dedicated acts as the service provider, and you must provide the necessary [configuration](../../integration/saml.md#configure-saml-support-in-gitlab) in order for GitLab to communicate with your IdP. This is provided during onboarding.
- SAML [request signing](../../integration/saml.md#sign-saml-authentication-requests-optional), [group sync](../../user/group/saml_sso/group_sync.md#configure-saml-group-sync), and [SAML groups](../../integration/saml.md#configure-users-based-on-saml-group-membership) are supported.
- Networking:
- Public connectivity with support for IP Allowlists. During onboarding, you can optionally specify a list of IP addresses that can access your GitLab Dedicated instance. Subsequently, when an IP not on the allowlist tries to access your instance the connection is refused.
- Optional. Private connectivity via [AWS PrivateLink](https://aws.amazon.com/privatelink/).
You can specify an AWS IAM Principal and preferred Availability Zones during onboarding to enable this functionality. Both Ingress and Egress PrivateLinks are supported. When connecting to an internal service running in your VPC over HTTPS via PrivateLink, GitLab Dedicated supports the ability to use a private SSL certificate, which can be provided during onboarding.
- Upgrades:
- Monthly upgrades tracking one release behind the latest (n-1), with the latest security release.
- Out of band security patches provided for high severity releases.
- Backups: Regular backups taken and tested.
- Choice of cloud region: Upon onboarding, choose the cloud region where you want to deploy your instance. Some AWS regions have limited features and as a result, we are not able to deploy production instances to those regions. See below for the [full list of regions](#aws-regions-not-supported) not currently supported.
- Security: Data encrypted at rest and in transit using latest encryption standards.
- Application: Self-managed [Ultimate feature set](https://about.gitlab.com/pricing/feature-comparison/) with the exception of the unsupported features [listed below](#features-that-are-not-available).
### Data residency
GitLab Dedicated allows you to select the cloud region where your data will be stored. Upon [onboarding](../../administration/dedicated/index.md#onboarding), choose the cloud region where you want to deploy your Dedicated instance. Some AWS regions have limited features and as a result, we are not able to deploy production instances to those regions. See below for the [full list of regions](#aws-regions-not-supported) not supported.
### Availability and scalability
GitLab Dedicated leverages the GitLab [Cloud Native Hybrid reference architectures](../../administration/reference_architectures/index.md#cloud-native-hybrid) with high availability enabled. When [onboarding](../../administration/dedicated/index.md#onboarding), GitLab will match you to the closest reference architecture size based on your number of users. Learn about the [current Service Level Objective](https://about.gitlab.com/handbook/engineering/infrastructure/team/gitlab-dedicated/slas/#current-service-level-objective).
#### Disaster Recovery
When [onboarding](../../administration/dedicated/index.md#onboarding) to GitLab Dedicated, you can provide a Secondary AWS region in which your data is stored. This region is used to recover your GitLab Dedicated instance in case of a disaster. Regular backups of all GitLab Dedicated datastores (including Database and Git repositories) are taken and tested regularly and stored in your desired secondary region. GitLab Dedicated also provides the ability to store copies of these backups in a separate cloud region of choice for greater redundancy.
For more information, read about the [recovery plan for GitLab Dedicated](https://about.gitlab.com/handbook/engineering/infrastructure/team/gitlab-dedicated/slas/#disaster-recovery-plan) as well as RPO and RTO targets.
### Security
#### Authentication and authorization
GitLab Dedicated supports instance-level [SAML OmniAuth](../../integration/saml.md) functionality. Your GitLab Dedicated instance acts as the service provider, and you must provide the necessary [configuration](../../integration/saml.md#configure-saml-support-in-gitlab) in order for GitLab to communicate with your IdP. For more information, see how to [configure SAML](../../administration/dedicated/index.md#saml) for your instance.
- SAML [request signing](../../integration/saml.md#sign-saml-authentication-requests-optional), [group sync](../../user/group/saml_sso/group_sync.md#configure-saml-group-sync), and [SAML groups](../../integration/saml.md#configure-users-based-on-saml-group-membership) are supported.
#### Secure networking
GitLab Dedicated offers public connectivity by default with support for IP allowlists. You can [optionally specify a list of IP addresses](../../administration/dedicated/index.md#ip-allowlist) that can access your GitLab Dedicated instance. Subsequently, when an IP not on the allowlist tries to access your instance the connection is refused.
Private connectivity via [AWS PrivateLink](https://aws.amazon.com/privatelink/) is also offered as an option. Both [inbound](../../administration/dedicated/index.md#inbound-private-link) and [outbound](../../administration/dedicated/index.md#outbound-private-link) PrivateLinks are supported. When connecting to an internal service running in your VPC over HTTPS via PrivateLink, GitLab Dedicated supports the ability to use a private SSL certificate, which can be provided when [updating your instance configuration](../../administration/dedicated/index.md#custom-certificates).
#### Encryption
Data is encrypted at rest and in transit using the latest encryption standards.
### Compliance
#### Certifications
GitLab Dedicated offers the following [compliance certifications](https://about.gitlab.com/security/):
- SOC 2 Type 1 Report (Security and Confidentiality criteria)
- ISO/IEC 27001:2013
- ISO/IEC 27017:2015
- ISO/IEC 27018:2019
#### Isolation
As a single-tenant SaaS service, GitLab Dedicated provides infrastructure-level isolation of your GitLab environment. Your environment is placed into a separate AWS account from other tenants. This AWS account contains all of the underlying infrastructure necessary to host the GitLab application and your data stays within the account boundary. You administer the application while GitLab manages the underlying infrastructure. Tenant environments are also completely isolated from GitLab.com.
#### Access controls
GitLab Dedicated adheres to the [principle of least privilege](https://about.gitlab.com/handbook/security/access-management-policy.html#principle-of-least-privilege) to control access to customer tenant environments. Tenant AWS accounts live under a top-level GitLab Dedicated AWS parent organization. Access to the AWS Organization is restricted to select GitLab team members. All user accounts within the AWS Organization follow the overall GitLab Access Management Policy [outlined here](https://about.gitlab.com/handbook/security/access-management-policy.html). Direct access to customer tenant environments is restricted to a single Hub account. The GitLab Dedicated Control Plane uses the Hub account to perform automated actions over tenant accounts when managing environments. Similarly, GitLab Dedicated engineers do not have direct access to customer tenant environments. In break glass situations, where access to resources in the tenant environment is required to address a high-severity issue, GitLab engineers must go through the Hub account to manage those resources. This is done via an approval process, and after permission is granted, the engineer will assume an IAM role on a temporary basis to access tenant resources through the Hub account. All actions within the hub account and tenant account are logged to CloudTrail.
Inside tenant accounts, GitLab leverages Intrusion Detection and Malware Scanning capabilities from AWS GuardDuty. Infrastructure logs are monitored by the GitLab Security Incident Response Team to detect anomalous events.
#### Audit and observability
GitLab Dedicated provides access to [audit and system logs](../../administration/dedicated/index.md#access-to-application-logs) generated by the application.
### Maintenance
GitLab leverages [weekly maintenance windows](../../administration/dedicated/index.md#maintenance-window) to keep your instance up to date, fix security issues, and ensure the overall reliability and performance of your environment.
#### Upgrades
GitLab performs monthly upgrades to your instance with the latest security release during your preferred [maintenance window](../../administration/dedicated/index.md#maintenance-window) tracking one release behind the latest GitLab release. For example, if the latest version of GitLab available is 15.8, GitLab Dedicated runs on 15.7.
#### Unscheduled maintenance
GitLab may conduct unscheduled maintenance to address high-severity issues affecting the security, availability, or reliability of your instance.
### Application
GitLab Dedicated comes with the self-managed [Ultimate feature set](https://about.gitlab.com/pricing/feature-comparison/) with the exception of the unsupported features [listed below](#features-that-are-not-available).
#### GitLab Runners
With GitLab Dedicated, you must [install the GitLab Runner application](https://docs.gitlab.com/runner/install/index.html) on infrastructure that you own or manage. If hosting GitLab Runners on AWS, you can avoid having requests from the Runner fleet route through the public internet by setting up a secure connection from the Runner VPC to the GitLab Dedicated endpoint via AWS Private Link. Learn more about [networking options](#secure-networking).
## Features that are not available
@ -53,7 +116,7 @@ The following GitLab application features are not available:
The following features will not be supported:
- Mattermost
- Server-side Git hooks
- Server-side Git hooks. Use [push rules](../../user/project/repository/push_rules.md) instead.
### GitLab Dedicated service features

View File

@ -78,3 +78,5 @@ You may have connectivity issues due to the following reasons:
- If the curl command returns a failure, either:
- [Configure a proxy](https://docs.gitlab.com/omnibus/settings/environment-variables.html) in `gitlab.rb` to point to your server.
- Contact your network administrator to make changes to the proxy.
- If an SSL inspection appliance is used, you must add the appliance's root CA certificate to `/etc/gitlab/trusted-certs` on the server, then run `gitlab-ctl reconfigure`.

View File

@ -48,6 +48,7 @@ When deleting users, you can either:
- Delete just the user. Not all associated records are deleted with the user. Instead of being deleted, these records
are moved to a system-wide user with the username Ghost User. The Ghost User's purpose is to act as a container for
such records. Any commits made by a deleted user still display the username of the original user.
The user's personal projects are deleted, not moved to the Ghost User.
- Delete the user and their contributions, including:
- Abuse reports.
- Award emojis.

View File

@ -65,7 +65,9 @@ module API
agent = ::Clusters::AgentsFinder.new(user_project, current_user).find(params[:agent_id])
result = ::Clusters::AgentTokens::CreateService.new(
container: agent.project, current_user: current_user, params: token_params.merge(agent_id: agent.id)
agent: agent,
current_user: current_user,
params: token_params
).execute
bad_request!(result[:message]) if result[:status] == :error
@ -86,8 +88,9 @@ module API
agent = ::Clusters::AgentsFinder.new(user_project, current_user).find(params[:agent_id])
token = ::Clusters::AgentTokensFinder.new(agent, current_user).find(params[:token_id])
# Skipping explicit error handling and relying on exceptions
token.revoked!
result = ::Clusters::AgentTokens::RevokeService.new(token: token, current_user: current_user).execute
bad_request!(result[:message]) if result[:status] == :error
status :no_content
end

View File

@ -191,6 +191,12 @@ module API
name: :app_store_private_key,
type: String,
desc: 'The Apple App Store Connect Private Key'
},
{
required: true,
name: :app_store_private_key_file_name,
type: String,
desc: 'The Apple App Store Connect Private Key File Name'
}
],
'asana' => [

View File

@ -66,7 +66,6 @@ module Gitlab
push_frontend_feature_flag(:new_header_search)
push_frontend_feature_flag(:source_editor_toolbar)
push_frontend_feature_flag(:vscode_web_ide, current_user)
push_frontend_feature_flag(:integration_slack_app_notifications)
push_frontend_feature_flag(:full_path_project_search, current_user)
end

View File

@ -66,30 +66,33 @@ module Sidebars
@renderable_items ||= @items.select(&:render?)
end
# Returns a flattened representation of itself and all
# Returns a tree-like representation of itself and all
# renderable menu entries, with additional information
# on whether the item has an active route
# on whether the item(s) have an active route
def serialize_for_super_sidebar
id = object_id
[
# Parent entry _potentially_ removable once we have
# separate groupings for the Super Sidebar
{
id: id,
parent_id: nil,
title: title,
icon: sprite_icon,
link: link,
is_active: @context.route_is_active.call(active_routes)
},
# All renderable menu entries
renderable_items.map do |obj|
item = obj.serialize_for_super_sidebar(id)
items = serialize_items_for_super_sidebar
is_active = @context.route_is_active.call(active_routes) || items.any? { |item| item[:is_active] }
{
title: title,
icon: sprite_icon,
link: link,
is_active: is_active,
items: items
}
end
# Returns an array of renderable menu entries,
# with additional information on whether the item
# has an active route
def serialize_items_for_super_sidebar
# All renderable menu entries
renderable_items.map do |entry|
entry.serialize_for_super_sidebar.tap do |item|
active_routes = item.delete(:active_routes)
item[:is_active] = active_routes ? @context.route_is_active.call(active_routes) : false
item
end
].flatten
end
end
# Returns whether the menu has any renderable menu item

View File

@ -30,9 +30,8 @@ module Sidebars
true
end
def serialize_for_super_sidebar(parent_id = nil)
def serialize_for_super_sidebar
{
parent_id: parent_id,
title: title,
icon: sprite_icon,
link: link,

View File

@ -61,26 +61,10 @@ module Sidebars
@renderable_menus ||= @menus.select(&:render?)
end
# Serializes every renderable sub-menu and menu-item for the super sidebar
# A flattened list of menu items is grouped into the appropriate parent
# Serializes every renderable menu and returns a flattened result
def super_sidebar_menu_items
@super_sidebar_menu_items ||= renderable_menus
.flat_map(&:serialize_for_super_sidebar)
.each_with_object([]) do |item, acc|
# Finding the parent of an entry
parent_id = item.delete(:parent_id)
parent_idx = acc.find_index { |i| i[:id] == parent_id }
# Entries without a matching parent entry are top level
if parent_idx.nil?
acc.push(item)
else
acc[parent_idx][:items] ||= []
acc[parent_idx][:items].push(item)
# If any sub-item of a navigation item is active, its parent should be as well
acc[parent_idx][:is_active] ||= item[:is_active]
end
end
end
def super_sidebar_context_header

View File

@ -4798,13 +4798,28 @@ msgstr ""
msgid "Append the comment with %{tableflip}"
msgstr ""
msgid "AppleAppStore|Drag your Private Key file here or %{linkStart}click to upload%{linkEnd}."
msgstr ""
msgid "AppleAppStore|Drop your Private Key file to start the upload."
msgstr ""
msgid "AppleAppStore|Error: You are trying to upload something other than a Private Key file."
msgstr ""
msgid "AppleAppStore|Leave empty to use your current Private Key."
msgstr ""
msgid "AppleAppStore|The Apple App Store Connect Issuer ID."
msgstr ""
msgid "AppleAppStore|The Apple App Store Connect Key ID."
msgstr ""
msgid "AppleAppStore|The Apple App Store Connect Private Key."
msgid "AppleAppStore|The Apple App Store Connect Private Key (.p8)"
msgstr ""
msgid "AppleAppStore|Upload a new Apple App Store Connect Private Key (replace %{currentFileName})"
msgstr ""
msgid "AppleAppStore|Use GitLab to build and release an app in the Apple App Store."
@ -5933,9 +5948,18 @@ msgstr ""
msgid "Authorized applications (%{size})"
msgstr ""
msgid "AuthorizedApplication|Application secret was successfully updated."
msgstr ""
msgid "AuthorizedApplication|Are you sure you want to renew this secret? Any applications using the old secret will no longer be able to authenticate with GitLab."
msgstr ""
msgid "AuthorizedApplication|Are you sure you want to revoke this application?"
msgstr ""
msgid "AuthorizedApplication|Renew secret"
msgstr ""
msgid "AuthorizedApplication|Revoke application"
msgstr ""
@ -9644,6 +9668,9 @@ msgstr ""
msgid "ClusterAgent|User has insufficient permissions to create a token for this project"
msgstr ""
msgid "ClusterAgent|User has insufficient permissions to revoke the token for this project"
msgstr ""
msgid "ClusterAgent|You have insufficient permissions to create a cluster agent for this project"
msgstr ""
@ -22860,10 +22887,13 @@ msgstr ""
msgid "Integrations|Default settings are inherited from the instance level."
msgstr ""
msgid "Integrations|Edit project alias"
msgid "Integrations|Drag your file here or %{linkStart}click to upload%{linkEnd}."
msgstr ""
msgid "Integrations|Enable GitLab.com slash commands in a Slack workspace."
msgid "Integrations|Drop your file to start the upload."
msgstr ""
msgid "Integrations|Edit project alias"
msgstr ""
msgid "Integrations|Enable SSL verification"
@ -22881,6 +22911,9 @@ msgstr ""
msgid "Integrations|Enter your alias"
msgstr ""
msgid "Integrations|Error: You are trying to upload something other than an allowed file."
msgstr ""
msgid "Integrations|Failed to link namespace. Please try again."
msgstr ""
@ -43194,7 +43227,7 @@ msgstr ""
msgid "The scan has been created."
msgstr ""
msgid "The secret is only available when you first create the application."
msgid "The secret is only available when you create the application or renew the secret."
msgstr ""
msgid "The snippet can be accessed without any authentication."

View File

@ -3,10 +3,7 @@
module QA
# https://github.com/gitlab-qa-github/import-test <- project under test
#
RSpec.describe 'Manage', product_group: :import, quarantine: {
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391228',
type: :waiting_on
} do
RSpec.describe 'Manage', product_group: :import do
describe 'GitHub import' do
include_context 'with github import'

View File

@ -18,7 +18,10 @@ module QA
end
end
context 'when added to parent group' do
context 'when added to parent group', quarantine: {
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/392816',
type: :investigating
} do
let!(:parent_group_user) do
Resource::User.fabricate_via_api! do |user|
user.api_client = admin_api_client

View File

@ -1,10 +1,7 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Manage', product_group: :import, quarantine: {
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391230',
type: :waiting_on
} do
RSpec.describe 'Manage', product_group: :import do
describe 'GitHub import' do
include_context 'with github import'

View File

@ -38,6 +38,30 @@ RSpec.describe Admin::ApplicationsController do
end
end
describe 'PUT #renew' do
let(:oauth_params) do
{
id: application.id
}
end
subject { put :renew, params: oauth_params }
it { is_expected.to have_gitlab_http_status(:ok) }
it { expect { subject }.to change { application.reload.secret } }
context 'when renew fails' do
before do
allow_next_found_instance_of(Doorkeeper::Application) do |application|
allow(application).to receive(:save).and_return(false)
end
end
it { expect { subject }.not_to change { application.reload.secret } }
it { is_expected.to redirect_to(admin_application_url(application)) }
end
end
describe 'POST #create' do
context 'with hash_oauth_secrets flag off' do
before do

View File

@ -188,6 +188,55 @@ RSpec.describe Groups::Settings::ApplicationsController do
end
end
describe 'PUT #renew' do
context 'when user is owner' do
before do
group.add_owner(user)
end
let(:oauth_params) do
{
group_id: group,
id: application.id
}
end
subject { put :renew, params: oauth_params }
it { is_expected.to have_gitlab_http_status(:ok) }
it { expect { subject }.to change { application.reload.secret } }
context 'when renew fails' do
before do
allow_next_found_instance_of(Doorkeeper::Application) do |application|
allow(application).to receive(:save).and_return(false)
end
end
it { expect { subject }.not_to change { application.reload.secret } }
it { is_expected.to redirect_to(group_settings_application_url(group, application)) }
end
end
context 'when user is not owner' do
before do
group.add_maintainer(user)
end
let(:oauth_params) do
{
group_id: group,
id: application.id
}
end
it 'renders a 404' do
put :renew, params: oauth_params
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'PATCH #update' do
context 'when user is owner' do
before do

View File

@ -71,6 +71,33 @@ RSpec.describe Oauth::ApplicationsController do
it_behaves_like 'redirects to 2fa setup page when the user requires it'
end
describe 'PUT #renew' do
let(:oauth_params) do
{
id: application.id
}
end
subject { put :renew, params: oauth_params }
it { is_expected.to have_gitlab_http_status(:ok) }
it { expect { subject }.to change { application.reload.secret } }
it_behaves_like 'redirects to login page when the user is not signed in'
it_behaves_like 'redirects to 2fa setup page when the user requires it'
context 'when renew fails' do
before do
allow_next_found_instance_of(Doorkeeper::Application) do |application|
allow(application).to receive(:save).and_return(false)
end
end
it { expect { subject }.not_to change { application.reload.secret } }
it { is_expected.to redirect_to(oauth_application_url(application)) }
end
end
describe 'GET #show' do
subject { get :show, params: { id: application.id } }

View File

@ -261,7 +261,8 @@ FactoryBot.define do
app_store_issuer_id { 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }
app_store_key_id { 'ABC1' }
app_store_private_key { File.read('spec/fixtures/ssl_key.pem') }
app_store_private_key_file_name { 'auth_key.p8' }
app_store_private_key { File.read('spec/fixtures/auth_key.p8') }
end
# this is for testing storing values inside properties, which is deprecated and will be removed in

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Upload Dropzone Field', feature_category: :integrations do
include_context 'project integration activation'
it 'uploads the file data to the correct form fields and updates the messaging correctly', :js, :aggregate_failures do
visit_project_integration('Apple App Store Connect')
expect(page).to have_content('Drag your Private Key file here or click to upload.')
expect(page).not_to have_content('auth_key.p8')
find("input[name='service[dropzone_file_name]']",
visible: false).set(Rails.root.join('spec/fixtures/auth_key.p8'))
expect(find("input[name='service[app_store_private_key]']",
visible: false).value).to eq(File.read(Rails.root.join('spec/fixtures/auth_key.p8')))
expect(find("input[name='service[app_store_private_key_file_name]']", visible: false).value).to eq('auth_key.p8')
expect(page).not_to have_content('Drag your Private Key file here or click to upload.')
expect(page).to have_content('auth_key.p8')
end
end

16
spec/fixtures/auth_key.p8 vendored Normal file
View File

@ -0,0 +1,16 @@
-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKS+CfS9GcRSdYSN
SzyH5QJQBr5umRL6E+KilOV39iYFO/9oHjUdapTRWkrwnNPCp7qaeck4Jr8iv14t
PVNDfNr76eGb6/3YknOAP0QOjLWunoC8kjU+N/JHU52NrUeX3qEy8EKV9LeCDJcB
kBk+Yejn9nypg8c7sLsn33CB6i3bAgMBAAECgYA2D26w80T7WZvazYr86BNMePpd
j2mIAqx32KZHzt/lhh40J/SRtX9+Kl0Y7nBoRR5Ja9u/HkAIxNxLiUjwg9r6cpg/
uITEF5nMt7lAk391BuI+7VOZZGbJDsq2ulPd6lO+C8Kq/PI/e4kXcIjeH6KwQsuR
5vrXfBZ3sQfflaiN4QJBANBt8JY2LIGQF8o89qwUpRL5vbnKQ4IzZ5+TOl4RLR7O
AQpJ81tGuINghO7aunctb6rrcKJrxmEH1whzComybrMCQQDKV49nOBudRBAIgG4K
EnLzsRKISUHMZSJiYTYnablof8cKw1JaQduw7zgrUlLwnroSaAGX88+Jw1f5n2Lh
Vlg5AkBDdUGnrDLtYBCDEQYZHblrkc7ZAeCllDOWjxUV+uMqlCv8A4Ey6omvY57C
m6I8DkWVAQx8VPtozhvHjUw80rZHAkB55HWHAM3h13axKG0htCt7klhPsZHpx6MH
EPjGlXIT+aW2XiPmK3ZlCDcWIenE+lmtbOpI159Wpk8BGXs/s/xBAkEAlAY3ymgx
63BDJEwvOb2IaP8lDDxNsXx9XJNVvQbv5n15vNsLHbjslHfAhAbxnLQ1fLhUPqSi
nNp/xedE1YxutQ==
-----END PRIVATE KEY-----

View File

@ -507,30 +507,21 @@ describe('IntegrationForm', () => {
const dummyHelp = 'Foo Help';
it.each`
integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true}
${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
integration | helpHtml | sections | shouldShowSections | shouldShowHelp
${INTEGRATION_FORM_TYPE_SLACK} | ${''} | ${[]} | ${false} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${dummyHelp} | ${[]} | ${false} | ${true}
${INTEGRATION_FORM_TYPE_SLACK} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${INTEGRATION_FORM_TYPE_SLACK} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true}
${'foo'} | ${''} | ${[]} | ${false} | ${false}
${'foo'} | ${dummyHelp} | ${[]} | ${false} | ${true}
${'foo'} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false}
${'foo'} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false}
`(
'$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration',
({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => {
'$sections sections, and "$helpHtml" helpHtml for "$integration" integration',
({ integration, helpHtml, sections, shouldShowSections, shouldShowHelp }) => {
createComponent({
provide: {
helpHtml,
glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
sections,
@ -553,20 +544,15 @@ describe('IntegrationForm', () => {
${false} | ${true} | ${'When having only the fields without a section'}
`('$description', ({ hasSections, hasFieldsWithoutSections }) => {
it.each`
prefix | integration | shouldUpgradeSlack | flagIsOn | shouldShowAlert
${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true} | ${true}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${true} | ${false}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${false} | ${false}
${'does not'} | ${'foo'} | ${true} | ${true} | ${false}
${'does not'} | ${'foo'} | ${false} | ${true} | ${false}
${'does not'} | ${'foo'} | ${true} | ${false} | ${false}
prefix | integration | shouldUpgradeSlack | shouldShowAlert
${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true}
${'does not'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${false}
${'does not'} | ${'foo'} | ${true} | ${false}
${'does not'} | ${'foo'} | ${false} | ${false}
`(
'$prefix render the upgrade warning when we are in "$integration" integration with the flag "$flagIsOn" and Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections',
({ integration, shouldUpgradeSlack, flagIsOn, shouldShowAlert }) => {
'$prefix render the upgrade warning when we are in "$integration" integration with Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections',
({ integration, shouldUpgradeSlack, shouldShowAlert }) => {
createComponent({
provide: {
glFeatures: { integrationSlackAppNotifications: flagIsOn },
},
customStateProps: {
shouldUpgradeSlack,
type: integration,

View File

@ -0,0 +1,57 @@
import { shallowMount } from '@vue/test-utils';
import IntegrationSectionAppleAppStore from '~/integrations/edit/components/sections/apple_app_store.vue';
import UploadDropzoneField from '~/integrations/edit/components/upload_dropzone_field.vue';
import { createStore } from '~/integrations/edit/store';
describe('IntegrationSectionAppleAppStore', () => {
let wrapper;
const createComponent = (componentFields) => {
const store = createStore({
customState: { ...componentFields },
});
wrapper = shallowMount(IntegrationSectionAppleAppStore, {
store,
});
};
const componentFields = (fileName = '') => {
return {
fields: [
{
name: 'app_store_private_key_file_name',
value: fileName,
},
],
};
};
const findUploadDropzoneField = () => wrapper.findComponent(UploadDropzoneField);
describe('computed properties', () => {
it('renders UploadDropzoneField with default values', () => {
createComponent(componentFields());
const field = findUploadDropzoneField();
expect(field.exists()).toBe(true);
expect(field.props()).toMatchObject({
label: 'The Apple App Store Connect Private Key (.p8)',
helpText: '',
});
});
it('renders UploadDropzoneField with custom values for an attached file', () => {
createComponent(componentFields('fileName.txt'));
const field = findUploadDropzoneField();
expect(field.exists()).toBe(true);
expect(field.props()).toMatchObject({
label: 'Upload a new Apple App Store Connect Private Key (replace fileName.txt)',
helpText: 'Leave empty to use your current Private Key.',
});
});
});
});

View File

@ -0,0 +1,88 @@
import { mount } from '@vue/test-utils';
import { GlAlert } from '@gitlab/ui';
import { nextTick } from 'vue';
import UploadDropzoneField from '~/integrations/edit/components/upload_dropzone_field.vue';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import { mockField } from '../mock_data';
describe('UploadDropzoneField', () => {
let wrapper;
const contentsInputName = 'service[app_store_private_key]';
const fileNameInputName = 'service[app_store_private_key_file_name]';
const createComponent = (props) => {
wrapper = mount(UploadDropzoneField, {
propsData: {
...mockField,
...props,
name: contentsInputName,
label: 'Input Label',
fileInputName: fileNameInputName,
},
});
};
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findUploadDropzone = () => wrapper.findComponent(UploadDropzone);
const findFileContentsHiddenInput = () => wrapper.find(`input[name="${contentsInputName}"]`);
const findFileNameHiddenInput = () => wrapper.find(`input[name="${fileNameInputName}"]`);
describe('template', () => {
it('adds the expected file inputFieldName', () => {
createComponent();
expect(findUploadDropzone().props('inputFieldName')).toBe('service[dropzone_file_name]');
});
it('adds a disabled, hidden text input for the file contents', () => {
createComponent();
expect(findFileContentsHiddenInput().attributes('name')).toBe(contentsInputName);
expect(findFileContentsHiddenInput().attributes('disabled')).toBeDefined();
});
it('adds a disabled, hidden text input for the file name', () => {
createComponent();
expect(findFileNameHiddenInput().attributes('name')).toBe(fileNameInputName);
expect(findFileNameHiddenInput().attributes('disabled')).toBeDefined();
});
});
describe('clearError', () => {
it('clears uploadError when called', async () => {
createComponent();
expect(findGlAlert().exists()).toBe(false);
findUploadDropzone().vm.$emit('error');
await nextTick();
expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toBe(
'Error: You are trying to upload something other than an allowed file.',
);
findGlAlert().vm.$emit('dismiss');
await nextTick();
expect(findGlAlert().exists()).toBe(false);
});
});
describe('onError', () => {
it('assigns uploadError to the supplied custom message', async () => {
const message = 'test error message';
createComponent({ errorMessage: message });
findUploadDropzone().vm.$emit('error');
await nextTick();
expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toBe(message);
});
});
});

View File

@ -15,6 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import {
getIssuesCountsQueryResponse,
getIssuesQueryResponse,
getIssuesQueryEmptyResponse,
filteredTokens,
locationSearch,
setSortPreferenceMutationResponse,
@ -154,7 +155,24 @@ describe('CE IssuesListApp component', () => {
router = new VueRouter({ mode: 'history' });
return mountFn(IssuesListApp, {
apolloProvider: createMockApollo(requestHandlers),
apolloProvider: createMockApollo(
requestHandlers,
{},
{
typePolicies: {
Query: {
fields: {
project: {
merge: true,
},
group: {
merge: true,
},
},
},
},
},
),
router,
provide: {
...defaultProvide,
@ -180,7 +198,6 @@ describe('CE IssuesListApp component', () => {
describe('IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.runOnlyPendingTimers();
return waitForPromises();
});
@ -247,7 +264,6 @@ describe('CE IssuesListApp component', () => {
mountFn: mount,
});
jest.runOnlyPendingTimers();
return waitForPromises();
});
@ -477,7 +493,12 @@ describe('CE IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
beforeEach(() => {
wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount });
wrapper = mountComponent({
provide: { hasAnyIssues: true },
mountFn: mount,
issuesQueryResponse: getIssuesQueryEmptyResponse,
});
return waitForPromises();
});
it('shows EmptyStateWithAnyIssues empty state', () => {
@ -599,7 +620,6 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
[mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')),
});
jest.runOnlyPendingTimers();
return waitForPromises();
});
@ -620,8 +640,9 @@ describe('CE IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
beforeEach(async () => {
wrapper = mountComponent();
await waitForPromises();
router.push = jest.fn();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
@ -641,19 +662,25 @@ describe('CE IssuesListApp component', () => {
describe.each`
event | params
${'next-page'} | ${{
page_after: 'endCursor',
page_after: 'endcursor',
page_before: undefined,
first_page_size: 20,
last_page_size: undefined,
search: undefined,
sort: 'created_date',
state: 'opened',
}}
${'previous-page'} | ${{
page_after: undefined,
page_before: 'startCursor',
page_before: 'startcursor',
first_page_size: undefined,
last_page_size: 20,
search: undefined,
sort: 'created_date',
state: 'opened',
}}
`('when "$event" event is emitted by IssuableList', ({ event, params }) => {
beforeEach(() => {
beforeEach(async () => {
wrapper = mountComponent({
data: {
pageInfo: {
@ -662,6 +689,7 @@ describe('CE IssuesListApp component', () => {
},
},
});
await waitForPromises();
router.push = jest.fn();
findIssuableList().vm.$emit(event);
@ -735,7 +763,6 @@ describe('CE IssuesListApp component', () => {
provide: { isProject },
issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)),
});
jest.runOnlyPendingTimers();
return waitForPromises();
});
@ -761,7 +788,6 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
issuesQueryResponse: jest.fn().mockResolvedValue(response()),
});
jest.runOnlyPendingTimers();
return waitForPromises();
});
@ -793,8 +819,6 @@ describe('CE IssuesListApp component', () => {
router.push = jest.fn();
findIssuableList().vm.$emit('sort', sortKey);
jest.runOnlyPendingTimers();
await nextTick();
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
@ -914,13 +938,13 @@ describe('CE IssuesListApp component', () => {
${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false}
${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true}
${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false}
`('$description', ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
`('$description', async ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse);
wrapper = mountComponent({
provide: { isPublicVisibilityRestricted, isSignedIn },
issuesQueryResponse: mockQuery,
});
jest.runOnlyPendingTimers();
await waitForPromises();
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
});
@ -929,7 +953,6 @@ describe('CE IssuesListApp component', () => {
describe('fetching issues', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.runOnlyPendingTimers();
});
it('fetches issue, incident, test case, and task types', () => {

View File

@ -101,6 +101,26 @@ export const getIssuesQueryResponse = {
},
};
export const getIssuesQueryEmptyResponse = {
data: {
project: {
id: '1',
__typename: 'Project',
issues: {
__persist: true,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'startcursor',
endCursor: 'endcursor',
},
nodes: [],
},
},
},
};
export const getIssuesCountsQueryResponse = {
data: {
project: {

View File

@ -1,5 +1,9 @@
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import fileUpload, { getFilename, validateImageName } from '~/lib/utils/file_upload';
import fileUpload, {
getFilename,
validateImageName,
validateFileFromAllowList,
} from '~/lib/utils/file_upload';
describe('File upload', () => {
beforeEach(() => {
@ -89,3 +93,19 @@ describe('file name validator', () => {
expect(validateImageName(file)).toBe('image.png');
});
});
describe('validateFileFromAllowList', () => {
it('returns true if the file type is in the allowed list', () => {
const allowList = ['.foo', '.bar'];
const fileName = 'file.foo';
expect(validateFileFromAllowList(fileName, allowList)).toBe(true);
});
it('returns false if the file type is in the allowed list', () => {
const allowList = ['.foo', '.bar'];
const fileName = 'file.baz';
expect(validateFileFromAllowList(fileName, allowList)).toBe(false);
});
});

View File

@ -68,18 +68,23 @@ describe('HelpCenter component', () => {
});
describe('showKeyboardShortcuts', () => {
let button;
beforeEach(() => {
jest.spyOn(wrapper.vm.$refs.dropdown, 'close');
window.toggleShortcutsHelp = jest.fn();
findButton('Keyboard shortcuts ?').click();
button = findButton('Keyboard shortcuts ?');
});
it('closes the dropdown', () => {
button.click();
expect(wrapper.vm.$refs.dropdown.close).toHaveBeenCalled();
});
it('shows the keyboard shortcuts modal', () => {
expect(window.toggleShortcutsHelp).toHaveBeenCalled();
// This relies on the event delegation set up by the Shortcuts class in
// ~/behaviors/shortcuts/shortcuts.js.
expect(button.classList.contains('js-shortcuts-modal-trigger')).toBe(true);
});
});

View File

@ -1,4 +1,4 @@
import { GlDropdownItem, GlDropdown } from '@gitlab/ui';
import { GlDropdownItem, GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue';
@ -9,7 +9,9 @@ describe('Deploy freeze timezone dropdown', () => {
let wrapper;
let store;
const createComponent = (searchTerm, selectedTimezone) => {
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const createComponent = async (searchTerm, selectedTimezone) => {
wrapper = shallowMountExtended(TimezoneDropdown, {
store,
propsData: {
@ -19,9 +21,8 @@ describe('Deploy freeze timezone dropdown', () => {
},
});
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ searchTerm });
findSearchBox().vm.$emit('input', searchTerm);
await nextTick();
};
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
@ -35,8 +36,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('No time zones found', () => {
beforeEach(() => {
createComponent('UTC timezone');
beforeEach(async () => {
await createComponent('UTC timezone');
});
it('renders empty results message', () => {
@ -45,8 +46,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Search term is empty', () => {
beforeEach(() => {
createComponent('');
beforeEach(async () => {
await createComponent('');
});
it('renders all timezones when search term is empty', () => {
@ -55,8 +56,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Time zones found', () => {
beforeEach(() => {
createComponent('Alaska');
beforeEach(async () => {
await createComponent('Alaska');
});
it('renders only the time zone searched for', () => {
@ -87,8 +88,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Selected time zone not found', () => {
beforeEach(() => {
createComponent('', 'Berlin');
beforeEach(async () => {
await createComponent('', 'Berlin');
});
it('renders empty selections', () => {
@ -101,8 +102,8 @@ describe('Deploy freeze timezone dropdown', () => {
});
describe('Selected time zone found', () => {
beforeEach(() => {
createComponent('', 'Europe/Berlin');
beforeEach(async () => {
await createComponent('', 'Europe/Berlin');
});
it('renders selected time zone as dropdown label', () => {

View File

@ -420,6 +420,12 @@ describe('WorkItemDetail component', () => {
expect(findParentButton().props('icon')).toBe(mockParent.parent.workItemType.iconName);
});
it('shows parent title and iid', () => {
expect(findParentButton().text()).toBe(
`${mockParent.parent.title} #${mockParent.parent.iid}`,
);
});
it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => {
expect(findParentButton().attributes().href).toBe('../../issues/5');
});
@ -441,6 +447,11 @@ describe('WorkItemDetail component', () => {
expect(findParentButton().attributes().href).toBe(mockParentObjective.parent.webUrl);
});
it('shows work item type and iid', () => {
const { iid, workItemType } = workItemQueryResponse.data.workItem;
expect(findParent().text()).toContain(`${workItemType.name} #${iid}`);
});
});
});

View File

@ -4,20 +4,14 @@ require 'spec_helper'
RSpec.describe Sidebars::Menu, feature_category: :navigation do
let(:menu) { described_class.new(context) }
let(:context) do
Sidebars::Context.new(current_user: nil, container: nil, route_is_active: ->(x) { x[:controller] == 'fooc' })
end
let(:menu_item) do
Sidebars::MenuItem.new(title: 'foo2', link: 'foo2', active_routes: { controller: 'fooc' })
end
let(:context) { Sidebars::Context.new(current_user: nil, container: nil) }
let(:nil_menu_item) { Sidebars::NilMenuItem.new(item_id: :foo) }
describe '#all_active_routes' do
it 'gathers all active routes of items and the current menu' do
menu.add_item(Sidebars::MenuItem.new(title: 'foo1', link: 'foo1', active_routes: { path: %w(bar test) }))
menu.add_item(menu_item)
menu.add_item(Sidebars::MenuItem.new(title: 'foo2', link: 'foo2', active_routes: { controller: 'fooc' }))
menu.add_item(Sidebars::MenuItem.new(title: 'foo3', link: 'foo3', active_routes: { controller: 'barc' }))
menu.add_item(nil_menu_item)
@ -29,39 +23,36 @@ RSpec.describe Sidebars::Menu, feature_category: :navigation do
end
describe '#serialize_for_super_sidebar' do
it 'returns itself and all renderable menu entries' do
menu.add_item(menu_item)
menu.add_item(Sidebars::MenuItem.new(title: 'foo3', link: 'foo3', active_routes: { controller: 'barc' }))
it 'returns a tree-like structure of itself and all menu items' do
menu.add_item(Sidebars::MenuItem.new(title: 'Is active', link: 'foo2', active_routes: { controller: 'fooc' }))
menu.add_item(Sidebars::MenuItem.new(title: 'Not active', link: 'foo3', active_routes: { controller: 'barc' }))
menu.add_item(nil_menu_item)
allow(context).to receive(:route_is_active).and_return(->(x) { x[:controller] == 'fooc' })
allow(menu).to receive(:title).and_return('Title')
allow(menu).to receive(:active_routes).and_return({ path: 'foo' })
allow(menu).to receive(:object_id).and_return(31)
expect(menu.serialize_for_super_sidebar).to eq([
expect(menu.serialize_for_super_sidebar).to eq(
{
id: 31,
parent_id: nil,
title: "Title",
icon: nil,
link: "foo2",
is_active: false
},
{
parent_id: 31,
title: "foo2",
icon: nil,
link: "foo2",
is_active: true
},
{
parent_id: 31,
title: "foo3",
icon: nil,
link: "foo3",
is_active: false
}
])
is_active: true,
items: [
{
title: "Is active",
icon: nil,
link: "foo2",
is_active: true
},
{
title: "Not active",
icon: nil,
link: "foo3",
is_active: false
}
]
})
end
end

View File

@ -7,6 +7,7 @@ RSpec.describe Sidebars::Panel, feature_category: :navigation do
let(:panel) { Sidebars::Panel.new(context) }
let(:menu1) { Sidebars::Menu.new(context) }
let(:menu2) { Sidebars::Menu.new(context) }
let(:menu3) { Sidebars::Menu.new(context) }
describe '#renderable_menus' do
it 'returns only renderable menus' do
@ -21,57 +22,21 @@ RSpec.describe Sidebars::Panel, feature_category: :navigation do
end
describe '#super_sidebar_menu_items' do
it "groups items under their parent and marks parent as active if a child item is active" do
it "serializes every renderable menu and returns a flattened result" do
panel.add_menu(menu1)
panel.add_menu(menu2)
panel.add_menu(menu3)
allow(menu1).to receive(:render?).and_return(true)
allow(menu2).to receive(:render?).and_return(false)
allow(menu1).to receive(:serialize_for_super_sidebar).and_return([
{
id: 31,
parent_id: nil,
title: "Title",
is_active: false
},
{
parent_id: "non_existent_which_makes_this_top_level",
title: "Title 2",
is_active: false
},
{
parent_id: 31,
title: "Title > Item 1",
is_active: true
},
{
parent_id: 31,
title: "Title > Item 2",
is_active: false
}
])
allow(menu1).to receive(:serialize_for_super_sidebar).and_return("foo")
expect(panel.super_sidebar_menu_items).to eq([
{
id: 31,
title: "Title",
is_active: true,
items: [
{
title: "Title > Item 1",
is_active: true
},
{
title: "Title > Item 2",
is_active: false
}
]
},
{
title: "Title 2",
is_active: false
}
])
allow(menu2).to receive(:render?).and_return(false)
allow(menu2).to receive(:serialize_for_super_sidebar).and_return("i-should-not-appear-in-results")
allow(menu3).to receive(:render?).and_return(true)
allow(menu3).to receive(:serialize_for_super_sidebar).and_return(%w[bar baz])
expect(panel.super_sidebar_menu_items).to eq(%w[foo bar baz])
end
end

View File

@ -1,80 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::Partitionable::PartitionedFilter, :aggregate_failures, feature_category: :continuous_integration do
before do
create_tables(<<~SQL)
CREATE TABLE _test_ci_jobs_metadata (
id serial NOT NULL,
partition_id int NOT NULL DEFAULT 10,
name text,
PRIMARY KEY (id, partition_id)
) PARTITION BY LIST(partition_id);
CREATE TABLE _test_ci_jobs_metadata_1
PARTITION OF _test_ci_jobs_metadata
FOR VALUES IN (10);
SQL
end
let(:model) do
Class.new(Ci::ApplicationRecord) do
include Ci::Partitionable::PartitionedFilter
self.primary_key = :id
self.table_name = :_test_ci_jobs_metadata
def self.name
'TestCiJobMetadata'
end
end
end
let!(:record) { model.create! }
let(:where_filter) do
/WHERE "_test_ci_jobs_metadata"."id" = #{record.id} AND "_test_ci_jobs_metadata"."partition_id" = 10/
end
describe '#save' do
it 'uses id and partition_id' do
record.name = 'test'
recorder = ActiveRecord::QueryRecorder.new { record.save! }
expect(recorder.log).to include(where_filter)
expect(record.name).to eq('test')
end
end
describe '#update' do
it 'uses id and partition_id' do
recorder = ActiveRecord::QueryRecorder.new { record.update!(name: 'test') }
expect(recorder.log).to include(where_filter)
expect(record.name).to eq('test')
end
end
describe '#delete' do
it 'uses id and partition_id' do
recorder = ActiveRecord::QueryRecorder.new { record.delete }
expect(recorder.log).to include(where_filter)
expect(model.count).to be_zero
end
end
describe '#destroy' do
it 'uses id and partition_id' do
recorder = ActiveRecord::QueryRecorder.new { record.destroy! }
expect(recorder.log).to include(where_filter)
expect(model.count).to be_zero
end
end
def create_tables(table_sql)
Ci::ApplicationRecord.connection.execute(table_sql)
end
end

View File

@ -40,28 +40,4 @@ RSpec.describe Ci::Partitionable do
it { expect(ci_model.ancestors).to include(described_class::Switch) }
end
context 'with partitioned options' do
before do
stub_const("#{described_class}::Testing::PARTITIONABLE_MODELS", [ci_model.name])
ci_model.include(described_class)
ci_model.partitionable scope: ->(r) { 1 }, partitioned: partitioned
end
context 'when partitioned is true' do
let(:partitioned) { true }
it { expect(ci_model.ancestors).to include(described_class::PartitionedFilter) }
it { expect(ci_model).to be_partitioned }
end
context 'when partitioned is false' do
let(:partitioned) { false }
it { expect(ci_model.ancestors).not_to include(described_class::PartitionedFilter) }
it { expect(ci_model).not_to be_partitioned }
end
end
end

View File

@ -12,6 +12,7 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do
it { is_expected.to validate_presence_of :app_store_issuer_id }
it { is_expected.to validate_presence_of :app_store_key_id }
it { is_expected.to validate_presence_of :app_store_private_key }
it { is_expected.to validate_presence_of :app_store_private_key_file_name }
it { is_expected.to allow_value('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').for(:app_store_issuer_id) }
it { is_expected.not_to allow_value('abcde').for(:app_store_issuer_id) }
it { is_expected.to allow_value(File.read('spec/fixtures/ssl_key.pem')).for(:app_store_private_key) }
@ -28,8 +29,8 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do
describe '#fields' do
it 'returns custom fields' do
expect(apple_app_store_integration.fields.pluck(:name)).to eq(%w[app_store_issuer_id app_store_key_id
app_store_private_key])
expect(apple_app_store_integration.fields.pluck(:name)).to match_array(%w[app_store_issuer_id app_store_key_id
app_store_private_key app_store_private_key_file_name])
end
end

View File

@ -2,14 +2,14 @@
require 'spec_helper'
RSpec.describe Clusters::AgentTokens::CreateService do
subject(:service) { described_class.new(container: project, current_user: user, params: params) }
RSpec.describe Clusters::AgentTokens::CreateService, feature_category: :kubernetes_management do
subject(:service) { described_class.new(agent: cluster_agent, current_user: user, params: params) }
let_it_be(:user) { create(:user) }
let(:cluster_agent) { create(:cluster_agent) }
let(:project) { cluster_agent.project }
let(:params) { { agent_id: cluster_agent.id, description: 'token description', name: 'token name' } }
let(:params) { { description: 'token description', name: 'token name' } }
describe '#execute' do
subject { service.execute }
@ -75,7 +75,7 @@ RSpec.describe Clusters::AgentTokens::CreateService do
it 'returns validation errors', :aggregate_failures do
expect(subject.status).to eq(:error)
expect(subject.message).to eq(["Agent must exist", "Name can't be blank"])
expect(subject.message).to eq(["Name can't be blank"])
end
end
end

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::AgentTokens::RevokeService, feature_category: :kubernetes_management do
describe '#execute' do
let(:agent) { create(:cluster_agent) }
let(:agent_token) { create(:cluster_agent_token, agent: agent) }
let(:project) { agent.project }
let(:user) { agent.created_by_user }
before do
project.add_maintainer(user)
end
context 'when user is authorized' do
before do
project.add_maintainer(user)
end
context 'when user revokes agent token' do
it 'succeeds' do
described_class.new(token: agent_token, current_user: user).execute
expect(agent_token.revoked?).to be true
end
end
context 'when there is a validation failure' do
before do
agent_token.name = '' # make the record invalid, as we require a name to be present
end
it 'fails without raising an error', :aggregate_failures do
result = described_class.new(token: agent_token, current_user: user).execute
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq(["Name can't be blank"])
end
end
end
context 'when user is not authorized' do
let(:unauthorized_user) { create(:user) }
before do
project.add_guest(unauthorized_user)
end
context 'when user attempts to revoke agent token' do
it 'fails' do
described_class.new(token: agent_token, current_user: unauthorized_user).execute
expect(agent_token.revoked?).to be false
end
end
end
end
end

View File

@ -74,6 +74,8 @@ Integration.available_integration_names.each do |integration|
hash.merge!(k => File.read('spec/fixtures/ssl_key.pem'))
elsif integration == 'apple_app_store' && k == :app_store_key_id
hash.merge!(k => 'ABC1')
elsif integration == 'apple_app_store' && k == :app_store_private_key_file_name
hash.merge!(k => 'ssl_key.pem')
else
hash.merge!(k => "someword")
end

View File

@ -36,7 +36,7 @@ RSpec.shared_examples 'manage applications' do
validate_application(application_name_changed, 'No')
expect(page).not_to have_link('Continue')
expect(page).to have_content _('The secret is only available when you first create the application')
expect(page).to have_content _('The secret is only available when you create the application or renew the secret.')
visit_applications_path