Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
fb336d5f6b
commit
68aa32736b
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
f64a3cb1963dde07eaaae9d331ebf1e5e52050435b38f9b6727a53f04808b723
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 site’s 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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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-----
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue