summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-02-22 15:07:57 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-02-22 15:07:57 +0000
commit68aa32736b50c3609348f3bf740b81a2dfd1fb25 (patch)
tree801bc83d3ff80e58cf68cf1c9f33a164c36eb7de
parentfb336d5f6b8b2c8f3131ee97a68ebc80c64a0223 (diff)
downloadgitlab-ce-68aa32736b50c3609348f3bf740b81a2dfd1fb25.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/behaviors/shortcuts.js6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js5
-rw-r--r--app/assets/javascripts/integrations/constants.js2
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_forms/section.vue4
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue73
-rw-r--r--app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue143
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue7
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js7
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue12
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss3
-rw-r--r--app/controllers/admin/applications_controller.rb13
-rw-r--r--app/controllers/concerns/integrations/params.rb1
-rw-r--r--app/controllers/groups/settings/applications_controller.rb13
-rw-r--r--app/controllers/oauth/applications_controller.rb13
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/create.rb4
-rw-r--r--app/graphql/mutations/clusters/agent_tokens/revoke.rb3
-rw-r--r--app/models/concerns/ci/partitionable.rb10
-rw-r--r--app/models/concerns/ci/partitionable/partitioned_filter.rb41
-rw-r--r--app/models/integrations/apple_app_store.rb18
-rw-r--r--app/services/clusters/agent_tokens/create_service.rb16
-rw-r--r--app/services/clusters/agent_tokens/revoke_service.rb33
-rw-r--r--app/views/admin/applications/show.html.haml1
-rw-r--r--app/views/doorkeeper/applications/show.html.haml3
-rw-r--r--app/views/groups/settings/applications/show.html.haml3
-rw-r--r--app/views/shared/doorkeeper/applications/_show.html.haml9
-rw-r--r--app/views/shared/doorkeeper/applications/_update_form.html.haml3
-rw-r--r--app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml2
-rw-r--r--config/feature_flags/development/integration_slack_app_notifications.yml8
-rw-r--r--config/routes.rb1
-rw-r--r--config/routes/admin.rb4
-rw-r--r--config/routes/group.rb4
-rw-r--r--db/post_migrate/20230221125148_add_fk_to_p_ci_builds_metadata_partitions_on_partition_id_and_build_id.rb41
-rw-r--r--db/schema_migrations/202302211251481
-rw-r--r--db/structure.sql3
-rw-r--r--doc/administration/audit_events.md9
-rw-r--r--doc/administration/geo/replication/upgrading_the_geo_sites.md1
-rw-r--r--doc/architecture/blueprints/ci_pipeline_components/index.md10
-rw-r--r--doc/subscriptions/gitlab_dedicated/index.md91
-rw-r--r--doc/user/admin_area/license.md2
-rw-r--r--doc/user/profile/account/delete_account.md1
-rw-r--r--lib/api/clusters/agent_tokens.rb9
-rw-r--r--lib/api/helpers/integrations_helpers.rb6
-rw-r--r--lib/gitlab/gon_helper.rb1
-rw-r--r--lib/sidebars/menu.rb41
-rw-r--r--lib/sidebars/menu_item.rb3
-rw-r--r--lib/sidebars/panel.rb18
-rw-r--r--locale/gitlab.pot41
-rw-r--r--qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb5
-rw-r--r--qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb5
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/import/import_github_repo_spec.rb5
-rw-r--r--spec/controllers/admin/applications_controller_spec.rb24
-rw-r--r--spec/controllers/groups/settings/applications_controller_spec.rb49
-rw-r--r--spec/controllers/oauth/applications_controller_spec.rb27
-rw-r--r--spec/factories/integrations.rb3
-rw-r--r--spec/features/projects/integrations/apple_app_store_spec.rb24
-rw-r--r--spec/fixtures/auth_key.p816
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js50
-rw-r--r--spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js57
-rw-r--r--spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js88
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js55
-rw-r--r--spec/frontend/issues/list/mock_data.js20
-rw-r--r--spec/frontend/lib/utils/file_upload_spec.js22
-rw-r--r--spec/frontend/super_sidebar/components/help_center_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js31
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js11
-rw-r--r--spec/lib/sidebars/menu_spec.rb55
-rw-r--r--spec/lib/sidebars/panel_spec.rb57
-rw-r--r--spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb80
-rw-r--r--spec/models/concerns/ci/partitionable_spec.rb24
-rw-r--r--spec/models/integrations/apple_app_store_spec.rb5
-rw-r--r--spec/services/clusters/agent_tokens/create_service_spec.rb8
-rw-r--r--spec/services/clusters/agent_tokens/revoke_service_spec.rb59
-rw-r--r--spec/support/shared_contexts/features/integrations/integrations_shared_context.rb2
-rw-r--r--spec/support/shared_examples/features/manage_applications_shared_examples.rb2
76 files changed, 1117 insertions, 435 deletions
diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js
index 12fdb2e2981..22a8be92e52 100644
--- a/app/assets/javascripts/behaviors/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts.js
@@ -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;
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 7a1577e97d5..6a7ce4f1c41 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -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();
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index 55576f6e9f9..e9bcc789bcb 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -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 = {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index d671ec33bcb..f119668048d 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -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)
);
diff --git a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
index ce39954735a..d33f0ba3171 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_forms/section.vue
@@ -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,
diff --git a/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
new file mode 100644
index 00000000000..775600a9a62
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/apple_app_store.vue
@@ -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>
diff --git a/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue b/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue
new file mode 100644
index 00000000000..fbed2547c05
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/upload_dropzone_field.vue
@@ -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>
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index 6c46013e4f9..3a0287920ff 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -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,
},
diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
index f99a4927338..c80d3f24d07 100644
--- a/app/assets/javascripts/lib/utils/file_upload.js
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -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);
+};
diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue
index ee4c50e7705..5e24f20f40a 100644
--- a/app/assets/javascripts/super_sidebar/components/help_center.vue
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -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() {
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 262c093a1d0..1d612018990 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -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
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 704ff5d4ee2..c8a84988859 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -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;
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index d66b3cb4366..91fc1bf489d 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -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.')
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 4d181ded071..b8b93814e2b 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -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,
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
index 3557d485422..6fb2b65feb8 100644
--- a/app/controllers/groups/settings/applications_controller.rb
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -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.')
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 3b78b997da1..ac3a42f2e7d 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -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
diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb
index c10e1633350..1b104652bd2 100644
--- a/app/graphql/mutations/clusters/agent_tokens/create.rb
+++ b/app/graphql/mutations/clusters/agent_tokens/create.rb
@@ -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
diff --git a/app/graphql/mutations/clusters/agent_tokens/revoke.rb b/app/graphql/mutations/clusters/agent_tokens/revoke.rb
index 974db976f1d..6e988799921 100644
--- a/app/graphql/mutations/clusters/agent_tokens/revoke.rb
+++ b/app/graphql/mutations/clusters/agent_tokens/revoke.rb
@@ -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
diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb
index d6ba0f4488f..64f8a04c19f 100644
--- a/app/models/concerns/ci/partitionable.rb
+++ b/app/models/concerns/ci/partitionable.rb
@@ -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
diff --git a/app/models/concerns/ci/partitionable/partitioned_filter.rb b/app/models/concerns/ci/partitionable/partitioned_filter.rb
deleted file mode 100644
index 4adae3be26a..00000000000
--- a/app/models/concerns/ci/partitionable/partitioned_filter.rb
+++ /dev/null
@@ -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
diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb
index 84185542939..34da4c0f4b8 100644
--- a/app/models/integrations/apple_app_store.rb
+++ b/app/models/integrations/apple_app_store.rb
@@ -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
diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb
index 2539ffdc5ba..e0c0b613adc 100644
--- a/app/services/clusters/agent_tokens/create_service.rb
+++ b/app/services/clusters/agent_tokens/create_service.rb
@@ -2,13 +2,21 @@
module Clusters
module AgentTokens
- class CreateService < ::BaseContainerService
+ class CreateService
ALLOWED_PARAMS = %i[agent_id description name].freeze
+ attr_reader :agent, :current_user, :params
+
+ 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, container)
+ return error_no_permissions unless current_user.can?(:create_cluster, agent.project)
- token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
+ 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
diff --git a/app/services/clusters/agent_tokens/revoke_service.rb b/app/services/clusters/agent_tokens/revoke_service.rb
new file mode 100644
index 00000000000..247cedb8e38
--- /dev/null
+++ b/app/services/clusters/agent_tokens/revoke_service.rb
@@ -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
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 212e3eeb951..b93a3c5d7fe 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -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
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 0428b9c340c..d087d85a94e 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -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)
diff --git a/app/views/groups/settings/applications/show.html.haml b/app/views/groups/settings/applications/show.html.haml
index 4a83d96aae4..e24aa993b26 100644
--- a/app/views/groups/settings/applications/show.html.haml
+++ b/app/views/groups/settings/applications/show.html.haml
@@ -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)
diff --git a/app/views/shared/doorkeeper/applications/_show.html.haml b/app/views/shared/doorkeeper/applications/_show.html.haml
index 5b0cff2c1c0..4e36eb48acb 100644
--- a/app/views/shared/doorkeeper/applications/_show.html.haml
+++ b/app/views/shared/doorkeeper/applications/_show.html.haml
@@ -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')
diff --git a/app/views/shared/doorkeeper/applications/_update_form.html.haml b/app/views/shared/doorkeeper/applications/_update_form.html.haml
new file mode 100644
index 00000000000..1bee3288639
--- /dev/null
+++ b/app/views/shared/doorkeeper/applications/_update_form.html.haml
@@ -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'
diff --git a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
index 1e19d74df4c..27cf1aa9f73 100644
--- a/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
+++ b/app/views/shared/integrations/_slack_notifications_deprecation_alert.html.haml
@@ -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,
diff --git a/config/feature_flags/development/integration_slack_app_notifications.yml b/config/feature_flags/development/integration_slack_app_notifications.yml
deleted file mode 100644
index 4b9903b25c9..00000000000
--- a/config/feature_flags/development/integration_slack_app_notifications.yml
+++ /dev/null
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 8530923aa1a..c951934c933 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index 0d74ddeaabf..70d02609523 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -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]
diff --git a/config/routes/group.rb b/config/routes/group.rb
index 582f8bf9471..22c63482afa 100644
--- a/config/routes/group.rb
+++ b/config/routes/group.rb
@@ -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
diff --git a/db/post_migrate/20230221125148_add_fk_to_p_ci_builds_metadata_partitions_on_partition_id_and_build_id.rb b/db/post_migrate/20230221125148_add_fk_to_p_ci_builds_metadata_partitions_on_partition_id_and_build_id.rb
new file mode 100644
index 00000000000..9df03f03d2b
--- /dev/null
+++ b/db/post_migrate/20230221125148_add_fk_to_p_ci_builds_metadata_partitions_on_partition_id_and_build_id.rb
@@ -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
diff --git a/db/schema_migrations/20230221125148 b/db/schema_migrations/20230221125148
new file mode 100644
index 00000000000..35ec9b066bc
--- /dev/null
+++ b/db/schema_migrations/20230221125148
@@ -0,0 +1 @@
+f64a3cb1963dde07eaaae9d331ebf1e5e52050435b38f9b6727a53f04808b723 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index d91ddadf7b1..3c182d2b80c 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -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;
diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md
index 0d6f6630014..623ecc841ed 100644
--- a/doc/administration/audit_events.md
+++ b/doc/administration/audit_events.md
@@ -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:
diff --git a/doc/administration/geo/replication/upgrading_the_geo_sites.md b/doc/administration/geo/replication/upgrading_the_geo_sites.md
index 55395a55857..644232a2c9e 100644
--- a/doc/administration/geo/replication/upgrading_the_geo_sites.md
+++ b/doc/administration/geo/replication/upgrading_the_geo_sites.md
@@ -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).
diff --git a/doc/architecture/blueprints/ci_pipeline_components/index.md b/doc/architecture/blueprints/ci_pipeline_components/index.md
index f78cf59fca5..e75300a3110 100644
--- a/doc/architecture/blueprints/ci_pipeline_components/index.md
+++ b/doc/architecture/blueprints/ci_pipeline_components/index.md
@@ -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.
diff --git a/doc/subscriptions/gitlab_dedicated/index.md b/doc/subscriptions/gitlab_dedicated/index.md
index 2d684edb992..7ca00d11e1e 100644
--- a/doc/subscriptions/gitlab_dedicated/index.md
+++ b/doc/subscriptions/gitlab_dedicated/index.md
@@ -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
diff --git a/doc/user/admin_area/license.md b/doc/user/admin_area/license.md
index 5296a918f56..823f876539f 100644
--- a/doc/user/admin_area/license.md
+++ b/doc/user/admin_area/license.md
@@ -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`.
+ \ No newline at end of file
diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md
index a74fd8d5215..56aa545408f 100644
--- a/doc/user/profile/account/delete_account.md
+++ b/doc/user/profile/account/delete_account.md
@@ -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.
diff --git a/lib/api/clusters/agent_tokens.rb b/lib/api/clusters/agent_tokens.rb
index 68eef21903d..f0eb7ce2cd6 100644
--- a/lib/api/clusters/agent_tokens.rb
+++ b/lib/api/clusters/agent_tokens.rb
@@ -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
diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb
index 31328facd69..d13a78db01c 100644
--- a/lib/api/helpers/integrations_helpers.rb
+++ b/lib/api/helpers/integrations_helpers.rb
@@ -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' => [
diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb
index c9766ee095a..d7d06aa5271 100644
--- a/lib/gitlab/gon_helper.rb
+++ b/lib/gitlab/gon_helper.rb
@@ -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
diff --git a/lib/sidebars/menu.rb b/lib/sidebars/menu.rb
index 5faa3c827d7..d0fd8212671 100644
--- a/lib/sidebars/menu.rb
+++ b/lib/sidebars/menu.rb
@@ -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
diff --git a/lib/sidebars/menu_item.rb b/lib/sidebars/menu_item.rb
index 34adfca0da4..9cdde9acc0d 100644
--- a/lib/sidebars/menu_item.rb
+++ b/lib/sidebars/menu_item.rb
@@ -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,
diff --git a/lib/sidebars/panel.rb b/lib/sidebars/panel.rb
index 9428085a5e7..aa8ff177caa 100644
--- a/lib/sidebars/panel.rb
+++ b/lib/sidebars/panel.rb
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 05f7f0b3f65..afbdddc3475 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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|Drop your file to start the upload."
msgstr ""
-msgid "Integrations|Enable GitLab.com slash commands in a Slack workspace."
+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."
diff --git a/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb b/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb
index 9017aba8e4a..a10e95a860c 100644
--- a/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb
+++ b/qa/qa/specs/features/api/1_manage/import/import_github_repo_spec.rb
@@ -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'
diff --git a/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb b/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb
index 124b6c9cd44..2ae68fc64cf 100644
--- a/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb
+++ b/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb
@@ -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
diff --git a/qa/qa/specs/features/browser_ui/1_manage/import/import_github_repo_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/import/import_github_repo_spec.rb
index 461928cbf1f..b5a8df15ddc 100644
--- a/qa/qa/specs/features/browser_ui/1_manage/import/import_github_repo_spec.rb
+++ b/qa/qa/specs/features/browser_ui/1_manage/import/import_github_repo_spec.rb
@@ -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'
diff --git a/spec/controllers/admin/applications_controller_spec.rb b/spec/controllers/admin/applications_controller_spec.rb
index bf7707f177c..c9b3cf4bca7 100644
--- a/spec/controllers/admin/applications_controller_spec.rb
+++ b/spec/controllers/admin/applications_controller_spec.rb
@@ -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
diff --git a/spec/controllers/groups/settings/applications_controller_spec.rb b/spec/controllers/groups/settings/applications_controller_spec.rb
index b9457770ed6..8919360d276 100644
--- a/spec/controllers/groups/settings/applications_controller_spec.rb
+++ b/spec/controllers/groups/settings/applications_controller_spec.rb
@@ -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
diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb
index 9b16dc9a463..2b8017f1ff7 100644
--- a/spec/controllers/oauth/applications_controller_spec.rb
+++ b/spec/controllers/oauth/applications_controller_spec.rb
@@ -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 } }
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index 7740b2da911..dccca9ce30d 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -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
diff --git a/spec/features/projects/integrations/apple_app_store_spec.rb b/spec/features/projects/integrations/apple_app_store_spec.rb
new file mode 100644
index 00000000000..b6dc6557e20
--- /dev/null
+++ b/spec/features/projects/integrations/apple_app_store_spec.rb
@@ -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
diff --git a/spec/fixtures/auth_key.p8 b/spec/fixtures/auth_key.p8
new file mode 100644
index 00000000000..1b53126536e
--- /dev/null
+++ b/spec/fixtures/auth_key.p8
@@ -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-----
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index 383dfb36aa5..2d74fe0a7d8 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -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,
diff --git a/spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js b/spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js
new file mode 100644
index 00000000000..62f0439a13f
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/sections/apple_app_store_spec.js
@@ -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.',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js b/spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js
new file mode 100644
index 00000000000..36e20db0022
--- /dev/null
+++ b/spec/frontend/integrations/edit/components/upload_dropzone_field_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js
index 8281ce0ed1a..93b9868b38b 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -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', () => {
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index 1e8a81116f3..0332f68ddb6 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -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: {
diff --git a/spec/frontend/lib/utils/file_upload_spec.js b/spec/frontend/lib/utils/file_upload_spec.js
index f63af2fe0a4..509ddc7ce86 100644
--- a/spec/frontend/lib/utils/file_upload_spec.js
+++ b/spec/frontend/lib/utils/file_upload_spec.js
@@ -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);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js
index bc847a3e159..1d072c0ba3c 100644
--- a/spec/frontend/super_sidebar/components/help_center_spec.js
+++ b/spec/frontend/super_sidebar/components/help_center_spec.js
@@ -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);
});
});
diff --git a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
index c8351ed61d7..c891c4700c9 100644
--- a/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/timezone_dropdown/timezone_dropdown_spec.js
@@ -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', () => {
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 64a7502671e..38e1903ad47 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -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}`);
+ });
});
});
diff --git a/spec/lib/sidebars/menu_spec.rb b/spec/lib/sidebars/menu_spec.rb
index 983fc19ec0f..c84e04a738f 100644
--- a/spec/lib/sidebars/menu_spec.rb
+++ b/spec/lib/sidebars/menu_spec.rb
@@ -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
diff --git a/spec/lib/sidebars/panel_spec.rb b/spec/lib/sidebars/panel_spec.rb
index faba356ac70..2c1b9c73595 100644
--- a/spec/lib/sidebars/panel_spec.rb
+++ b/spec/lib/sidebars/panel_spec.rb
@@ -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(menu1).to receive(:serialize_for_super_sidebar).and_return("foo")
+
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
- }
- ])
-
- 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(: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
diff --git a/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb b/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb
deleted file mode 100644
index bb25d7d1665..00000000000
--- a/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb
+++ /dev/null
@@ -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
diff --git a/spec/models/concerns/ci/partitionable_spec.rb b/spec/models/concerns/ci/partitionable_spec.rb
index 430ef57d493..f3d33c971c7 100644
--- a/spec/models/concerns/ci/partitionable_spec.rb
+++ b/spec/models/concerns/ci/partitionable_spec.rb
@@ -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
diff --git a/spec/models/integrations/apple_app_store_spec.rb b/spec/models/integrations/apple_app_store_spec.rb
index 1a57f556895..b2a52c8aaf0 100644
--- a/spec/models/integrations/apple_app_store_spec.rb
+++ b/spec/models/integrations/apple_app_store_spec.rb
@@ -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
diff --git a/spec/services/clusters/agent_tokens/create_service_spec.rb b/spec/services/clusters/agent_tokens/create_service_spec.rb
index dc7abd1504b..519a3ba7ce5 100644
--- a/spec/services/clusters/agent_tokens/create_service_spec.rb
+++ b/spec/services/clusters/agent_tokens/create_service_spec.rb
@@ -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
diff --git a/spec/services/clusters/agent_tokens/revoke_service_spec.rb b/spec/services/clusters/agent_tokens/revoke_service_spec.rb
new file mode 100644
index 00000000000..24485a5f66d
--- /dev/null
+++ b/spec/services/clusters/agent_tokens/revoke_service_spec.rb
@@ -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
diff --git a/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb b/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb
index bf5158c9a92..178dad627ed 100644
--- a/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb
+++ b/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb
@@ -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
diff --git a/spec/support/shared_examples/features/manage_applications_shared_examples.rb b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
index b59f3f1e27b..239fd65c60a 100644
--- a/spec/support/shared_examples/features/manage_applications_shared_examples.rb
+++ b/spec/support/shared_examples/features/manage_applications_shared_examples.rb
@@ -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