diff options
59 files changed, 1597 insertions, 229 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 296a103cfe8..e21e2b1e446 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -76dabc8174f7978025f48adcfab0a19c85416531 +ca638e23ca921cf2f2f3cdc8a6ff033af667170b diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue index 98995730df4..b824a013f3b 100644 --- a/app/assets/javascripts/members/components/members_tabs.vue +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -1,40 +1,48 @@ <script> import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { queryToObject } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES, ACTIVE_TAB_QUERY_PARAM_NAME } from '../constants'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { + MEMBER_TYPES, + ACTIVE_TAB_QUERY_PARAM_NAME, + TAB_QUERY_PARAM_VALUES, + EE_TABS, +} from 'ee_else_ce/members/constants'; import MembersApp from './app.vue'; const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0; +export const TABS = [ + { + namespace: MEMBER_TYPES.user, + title: __('Members'), + }, + { + namespace: MEMBER_TYPES.group, + title: __('Groups'), + attrs: { 'data-qa-selector': 'groups_list_tab' }, + queryParamValue: TAB_QUERY_PARAM_VALUES.group, + }, + { + namespace: MEMBER_TYPES.invite, + title: __('Invited'), + canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.invite, + }, + { + namespace: MEMBER_TYPES.accessRequest, + title: __('Access requests'), + canManageMembersPermissionsRequired: true, + queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, + }, + ...EE_TABS, +]; + export default { name: 'MembersTabs', ACTIVE_TAB_QUERY_PARAM_NAME, - TABS: [ - { - namespace: MEMBER_TYPES.user, - title: __('Members'), - }, - { - namespace: MEMBER_TYPES.group, - title: __('Groups'), - attrs: { 'data-qa-selector': 'groups_list_tab' }, - queryParamValue: TAB_QUERY_PARAM_VALUES.group, - }, - { - namespace: MEMBER_TYPES.invite, - title: __('Invited'), - canManageMembersPermissionsRequired: true, - queryParamValue: TAB_QUERY_PARAM_VALUES.invite, - }, - { - namespace: MEMBER_TYPES.accessRequest, - title: __('Access requests'), - canManageMembersPermissionsRequired: true, - queryParamValue: TAB_QUERY_PARAM_VALUES.accessRequest, - }, - ], + TABS, components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton }, inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'], data() { @@ -43,20 +51,17 @@ export default { }; }, computed: { - ...mapState({ - userCount(state) { - return countComputed(state, MEMBER_TYPES.user); - }, - groupCount(state) { - return countComputed(state, MEMBER_TYPES.group); - }, - inviteCount(state) { - return countComputed(state, MEMBER_TYPES.invite); - }, - accessRequestCount(state) { - return countComputed(state, MEMBER_TYPES.accessRequest); - }, - }), + ...mapState( + Object.values(MEMBER_TYPES).reduce((getters, memberType) => { + return { + ...getters, + // eslint-disable-next-line @gitlab/require-i18n-strings + [`${memberType}Count`](state) { + return countComputed(state, memberType); + }, + }; + }, {}), + ), urlParams() { return Object.keys(queryToObject(window.location.search, { gatherArrays: true })); }, diff --git a/app/assets/javascripts/members/components/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue index 92b757ffcba..966eb90e402 100644 --- a/app/assets/javascripts/members/components/table/member_avatar.vue +++ b/app/assets/javascripts/members/components/table/member_avatar.vue @@ -6,7 +6,13 @@ import UserAvatar from '../avatars/user_avatar.vue'; export default { name: 'MemberAvatar', - components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, + components: { + UserAvatar, + InviteAvatar, + GroupAvatar, + AccessRequestAvatar: UserAvatar, + BannedAvatar: UserAvatar, + }, props: { memberType: { type: String, diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 3436bcab2fc..51eff428d63 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -1,5 +1,5 @@ <script> -import { MEMBER_TYPES } from '../../constants'; +import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import { isGroup, isDirectMember, diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 8c40cc3f29d..2fe816c7ea2 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -3,6 +3,12 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +// Overridden in EE +export const EE_APP_OPTIONS = {}; + +// Overridden in EE +export const EE_TABS = []; + export const FIELD_KEY_ACCOUNT = 'account'; export const FIELD_KEY_SOURCE = 'source'; export const FIELD_KEY_GRANTED = 'granted'; diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 0df876cabd7..34660f8f499 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -2,8 +2,8 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { parseDataAttributes } from '~/members/utils'; +import { MEMBER_TYPES } from 'ee_else_ce/members/constants'; import MembersTabs from './components/members_tabs.vue'; -import { MEMBER_TYPES } from './constants'; import membersStore from './store'; export const initMembersApp = (el, options) => { diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index fdc7bd39780..90a18d5cf5a 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -81,7 +81,7 @@ export default { </script> <template> - <settings-block :collapsible="false"> + <settings-block data-testid="container-expiration-policy-project-settings"> <template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template> <template #description> <span> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue index d75fb31fd98..7682754fdcb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue @@ -30,6 +30,11 @@ export default { type: String, required: true, }, + description: { + type: String, + required: false, + default: '', + }, }, }; </script> @@ -46,5 +51,10 @@ export default { {{ option.label }} </option> </gl-form-select> + <template v-if="description" #description> + <span data-testid="description" class="gl-text-gray-400"> + {{ description }} + </span> + </template> </gl-form-group> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue new file mode 100644 index 00000000000..1170407a349 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue @@ -0,0 +1,68 @@ +<script> +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + PACKAGES_CLEANUP_POLICY_TITLE, + PACKAGES_CLEANUP_POLICY_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue'; + +export default { + components: { + SettingsBlock, + GlAlert, + GlSprintf, + PackagesCleanupPolicyForm, + }, + inject: ['projectPath'], + i18n: { + FETCH_SETTINGS_ERROR_MESSAGE, + PACKAGES_CLEANUP_POLICY_TITLE, + PACKAGES_CLEANUP_POLICY_DESCRIPTION, + }, + apollo: { + packagesCleanupPolicy: { + query: packagesCleanupPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: (data) => data.project?.packagesCleanupPolicy || {}, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, + data() { + return { + fetchSettingsError: false, + packagesCleanupPolicy: {}, + }; + }, +}; +</script> + +<template> + <settings-block> + <template #title> {{ $options.i18n.PACKAGES_CLEANUP_POLICY_TITLE }}</template> + <template #description> + <span data-testid="description"> + <gl-sprintf :message="$options.i18n.PACKAGES_CLEANUP_POLICY_DESCRIPTION" /> + </span> + </template> + <template #default> + <gl-alert v-if="fetchSettingsError" variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> + </gl-alert> + <packages-cleanup-policy-form + v-else + v-model="packagesCleanupPolicy" + :is-loading="$apollo.queries.packagesCleanupPolicy.loading" + /> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue new file mode 100644 index 00000000000..b1751d5174a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue @@ -0,0 +1,137 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, + SET_CLEANUP_POLICY_BUTTON, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, +} from '~/packages_and_registries/settings/project/constants'; +import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; +import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; +import Tracking from '~/tracking'; +import ExpirationDropdown from './expiration_dropdown.vue'; + +export default { + components: { + GlButton, + ExpirationDropdown, + }, + mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + formOptions: formOptionsGenerator(), + i18n: { + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, + SET_CLEANUP_POLICY_BUTTON, + }, + data() { + return { + tracking: { + label: 'packages_cleanup_policies', + }, + mutationLoading: false, + }; + }, + computed: { + prefilledForm() { + return { + ...this.value, + keepNDuplicatedPackageFiles: this.findDefaultOption( + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + ), + }; + }, + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, + isSubmitButtonDisabled() { + return this.showLoadingIcon; + }, + isFieldDisabled() { + return this.showLoadingIcon; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles, + }; + }, + }, + methods: { + findDefaultOption(option) { + return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key; + }, + submit() { + this.track('submit_packages_cleanup_form'); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updatePackagesCleanupPolicyMutation, + variables: { + input: this.mutationVariables, + }, + }) + .then(({ data }) => { + const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? []; + if (errorMessage) { + throw errorMessage; + } else { + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); + } + }) + .catch(() => { + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE); + }) + .finally(() => { + this.mutationLoading = false; + }); + }, + onModelChange(newValue, model) { + this.$emit('input', { ...this.value, [model]: newValue }); + }, + }, +}; +</script> + +<template> + <form ref="form-element" @submit.prevent="submit"> + <div class="gl-md-max-w-50p"> + <expiration-dropdown + v-model="prefilledForm.keepNDuplicatedPackageFiles" + :disabled="isFieldDisabled" + :form-options="$options.formOptions.keepNDuplicatedPackageFiles" + :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL" + :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION" + name="keep-n-duplicated-package-files" + data-testid="keep-n-duplicated-package-files-dropdown" + @input="onModelChange($event, 'keepNDuplicatedPackageFiles')" + /> + </div> + <div class="gl-mt-7 gl-display-flex gl-align-items-center"> + <gl-button + data-testid="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + category="primary" + variant="confirm" + class="js-no-auto-disable gl-mr-4" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 95af19e6d85..710cfe7b1eb 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -1,15 +1,19 @@ <script> import ContainerExpirationPolicy from './container_expiration_policy.vue'; +import PackagesCleanupPolicy from './packages_cleanup_policy.vue'; export default { components: { ContainerExpirationPolicy, + PackagesCleanupPolicy, }, + inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'], }; </script> <template> - <section data-testid="registry-settings-app"> - <container-expiration-policy /> - </section> + <div> + <packages-cleanup-policy v-if="showPackageRegistrySettings" /> + <container-expiration-policy v-if="showContainerRegistrySettings" /> + </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 40f980d15fb..948520151ce 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -55,6 +55,31 @@ export const EXPIRATION_POLICY_FOOTER_NOTE = s__( 'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time', ); +export const PACKAGES_CLEANUP_POLICY_TITLE = s__( + 'PackageRegistry|Manage storage used by package assets', +); +export const PACKAGES_CLEANUP_POLICY_DESCRIPTION = s__( + 'PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets.', +); +export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__( + 'PackageRegistry|Number of duplicate assets to keep', +); +export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__( + 'PackageRegistry|Examples of assets include .pom & .jar files', +); + +export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles'; + +export const KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS = [ + { key: 'ONE_PACKAGE_FILE', label: 1, default: false }, + { key: 'TEN_PACKAGE_FILES', label: 10, default: false }, + { key: 'TWENTY_PACKAGE_FILES', label: 20, default: false }, + { key: 'THIRTY_PACKAGE_FILES', label: 30, default: false }, + { key: 'FORTY_PACKAGE_FILES', label: 40, default: false }, + { key: 'FIFTY_PACKAGE_FILES', label: 50, default: false }, + { key: 'ALL_PACKAGE_FILES', label: __('All'), default: true }, +]; + export const KEEP_N_OPTIONS = [ { key: 'ONE_TAG', variable: 1, default: false }, { key: 'FIVE_TAGS', variable: 5, default: false }, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql new file mode 100644 index 00000000000..a77ede37884 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/packages_cleanup_policy.fragment.graphql @@ -0,0 +1,4 @@ +fragment PackagesCleanupPolicyFields on PackagesCleanupPolicy { + keepNDuplicatedPackageFiles + nextRunAt +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql new file mode 100644 index 00000000000..31cdd67e881 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/packages_cleanup_policy.fragment.graphql" + +mutation updatePackagesCleanupPolicy($input: UpdatePackagesCleanupPolicyInput!) { + updatePackagesCleanupPolicy(input: $input) { + packagesCleanupPolicy { + ...PackagesCleanupPolicyFields + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql new file mode 100644 index 00000000000..0e9af253f2c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql @@ -0,0 +1,10 @@ +#import "../fragments/packages_cleanup_policy.fragment.graphql" + +query getProjectPackagesCleanupPolicy($projectPath: ID!) { + project(fullPath: $projectPath) { + id + packagesCleanupPolicy { + ...PackagesCleanupPolicyFields + } + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index 17c33073668..daf1da6eac8 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js @@ -20,6 +20,8 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, + showContainerRegistrySettings, + showPackageRegistrySettings, } = el.dataset; return new Vue({ el, @@ -34,6 +36,8 @@ export default () => { adminSettingsPath, tagsRegexHelpPagePath, helpPagePath, + showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings), + showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings), }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js index b577a051862..847965454e9 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/utils.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js @@ -1,5 +1,11 @@ import { n__ } from '~/locale'; -import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants'; +import { + KEEP_N_OPTIONS, + CADENCE_OPTIONS, + OLDER_THAN_OPTIONS, + KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME, + KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS, +} from './constants'; export const findDefaultOption = (options) => { const item = options.find((o) => o.default); @@ -25,5 +31,6 @@ export const formOptionsGenerator = () => { olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), cadence: CADENCE_OPTIONS, keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), + [KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME]: KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS, }; }; diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index c7c2f6f773e..62d47cb49b8 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -5,12 +5,11 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import { initMembersApp } from '~/members'; -import { MEMBER_TYPES } from '~/members/constants'; +import { MEMBER_TYPES, EE_APP_OPTIONS } from 'ee_else_ce/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; - -initMembersApp(document.querySelector('.js-group-members-list-app'), { +const APP_OPTIONS = { [MEMBER_TYPES.user]: { tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, @@ -61,7 +60,10 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), { tableFields: SHARED_FIELDS.concat('requested'), requestFormatter: groupMemberRequestFormatter, }, -}); + ...EE_APP_OPTIONS, +}; + +initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS); initInviteMembersModal(); initInviteGroupsModal(); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index fe51591c32d..f2c30870a68 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -61,6 +61,10 @@ export default { GlFormCheckbox, GlToggle, ConfirmDanger, + otherProjectSettings: () => + import( + 'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue' + ), }, mixins: [settingsMixin, glFeatureFlagsMixin()], @@ -905,6 +909,7 @@ export default { <template #help>{{ $options.i18n.pucWarningHelpText }}</template> </gl-form-checkbox> </project-setting-row> + <other-project-settings /> <confirm-danger v-if="isVisibilityReduced" button-variant="confirm" diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb new file mode 100644 index 00000000000..554e057ca83 --- /dev/null +++ b/app/channels/awareness_channel.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + REFRESH_INTERVAL = ENV.fetch("GITLAB_AWARENESS_REFRESH_INTERVAL_SEC", 60) + private_constant :REFRESH_INTERVAL + + # Produces a refresh interval value, based of the + # GITLAB_AWARENESS_REFRESH_INTERVAL_SEC environment variable or the given + # default. Makes sure, that the interval after a jitter is applied, is never + # less than half the predefined interval. + def self.refresh_interval(range: -10..10) + min = REFRESH_INTERVAL / 2.to_f + [min.to_i, REFRESH_INTERVAL.to_i + rand(range)].max.seconds + end + private_class_method :refresh_interval + + # keep clients updated about session membership + periodically every: self.refresh_interval do + transmit payload + end + + def subscribed + reject unless valid_subscription? + return if subscription_rejected? + + stream_for session, coder: ActiveSupport::JSON + + session.join(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + def unsubscribed + return if subscription_rejected? + + session.leave(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + # Allows a client to let the server know they are still around. This is not + # like a heartbeat mechanism. This can be triggered by any action that results + # in a meaningful "presence" update. Like scrolling the screen (debounce), + # window becoming active, user starting to type in a text field, etc. + def touch + session.touch!(current_user) + + transmit payload + end + + private + + def valid_subscription? + current_user.present? && path.present? + end + + def payload + { collaborators: collaborators } + end + + def collaborators + session.online_users_with_last_activity.map do |user, last_activity| + collaborator(user, last_activity) + end + end + + def collaborator(user, last_activity) + { + id: user.id, + name: user.name, + avatar_url: user.avatar_url(size: 36), + last_activity: last_activity, + last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( + Time.zone.now, last_activity + ) + } + end + + def session + @session ||= AwarenessSession.for(path) + end + + def path + params[:path] + end +end diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 37b23345d2a..2021961772a 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -9,7 +9,7 @@ module Groups::GroupMembersHelper { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } end - def group_members_app_data(group, members:, invited:, access_requests:, include_relations:, search:) + def group_members_app_data(group, members:, invited:, access_requests:, banned:, include_relations:, search:) { user: group_members_list_data(group, members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }), group: group_group_links_list_data(group, include_relations, search), diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index 20d40626449..ec64746d6b6 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -53,4 +53,14 @@ module PackagesHelper project.container_expiration_policy.nil? && project.container_repositories.exists? end + + def show_container_registry_settings(project) + Gitlab.config.registry.enabled && + Ability.allowed?(current_user, :admin_container_image, project) + end + + def show_package_registry_settings(project) + Gitlab.config.packages.enabled && + Ability.allowed?(current_user, :admin_package, project) + end end diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb index 602888182b1..a84a3454a27 100644 --- a/app/models/awareness_session.rb +++ b/app/models/awareness_session.rb @@ -143,17 +143,34 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass end end + def to_param + id&.to_s + end + + def to_s + "awareness_session=#{id}" + end + + def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) + users_with_last_activity.filter do |_user, last_activity| + user_online?(last_activity, threshold: threshold) + end + end + def users User.where(id: user_ids) end def users_with_last_activity - # where in (x, y, [...z]) is a set and does not maintain any order, we need to - # make sure to establish a stable order for both, the pairs returned from + # where in (x, y, [...z]) is a set and does not maintain any order, we need + # to make sure to establish a stable order for both, the pairs returned from # redis and the ActiveRecord query. Using IDs in ascending order. user_ids, last_activities = user_ids_with_last_activity .sort_by(&:first) .transpose + + return [] if user_ids.blank? + users = User.where(id: user_ids).order(id: :asc) users.zip(last_activities) end @@ -162,6 +179,10 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass attr_reader :id + def user_online?(last_activity, threshold:) + last_activity.to_i + threshold.to_i > Time.zone.now.to_i + end + # converts session id from hex to integer representation def id_i Integer(id, 16) if id.present? diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 850f25a6089..54270dc186e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -225,6 +225,10 @@ class ProjectPolicy < BasePolicy Gitlab.config.registry.enabled end + condition :packages_enabled do + Gitlab.config.packages.enabled + end + # `:read_project` may be prevented in EE, but `:read_project_for_iids` should # not. rule { guest | admin }.enable :read_project_for_iids @@ -795,6 +799,10 @@ class ProjectPolicy < BasePolicy enable :view_package_registry_project_settings end + rule { packages_enabled & can?(:admin_package) }.policy do + enable :view_package_registry_project_settings + end + private def user_is_user? diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 4150766d3d3..d9fef8940eb 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -26,6 +26,7 @@ members: @members, invited: @invited_members, access_requests: @requesters, + banned: @banned || [], include_relations: @include_relations, search: params[:search_groups]).to_json } } = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml index 378bb0f9306..1a7821d3268 100644 --- a/app/views/projects/settings/packages_and_registries/show.html.haml +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -8,6 +8,8 @@ keep_n_options: keep_n_options.to_json, older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, + show_container_registry_settings: show_container_registry_settings(@project).to_s, + show_package_registry_settings: show_package_registry_settings(@project).to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'), diff --git a/db/migrate/20220712025712_update_insert_or_update_vulnerability_reads_function.rb b/db/migrate/20220712025712_update_insert_or_update_vulnerability_reads_function.rb new file mode 100644 index 00000000000..55aeacfbecd --- /dev/null +++ b/db/migrate/20220712025712_update_insert_or_update_vulnerability_reads_function.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class UpdateInsertOrUpdateVulnerabilityReadsFunction < Gitlab::Database::Migration[2.0] + FUNCTION_NAME = 'insert_or_update_vulnerability_reads' + + enable_lock_retries! + + def up + execute(<<~SQL) + CREATE OR REPLACE FUNCTION #{FUNCTION_NAME}() RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + severity smallint; + state smallint; + report_type smallint; + resolved_on_default_branch boolean; + present_on_default_branch boolean; + BEGIN + IF (NEW.vulnerability_id IS NULL AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE')) THEN + RETURN NULL; + END IF; + + IF (TG_OP = 'UPDATE' AND OLD.vulnerability_id IS NOT NULL AND NEW.vulnerability_id IS NOT NULL) THEN + RETURN NULL; + END IF; + + SELECT + vulnerabilities.severity, vulnerabilities.state, vulnerabilities.report_type, vulnerabilities.resolved_on_default_branch, vulnerabilities.present_on_default_branch + INTO + severity, state, report_type, resolved_on_default_branch, present_on_default_branch + FROM + vulnerabilities + WHERE + vulnerabilities.id = NEW.vulnerability_id; + + IF present_on_default_branch IS NOT true THEN + RETURN NULL; + END IF; + + INSERT INTO vulnerability_reads (vulnerability_id, project_id, scanner_id, report_type, severity, state, resolved_on_default_branch, uuid, location_image, cluster_agent_id, casted_cluster_agent_id) + VALUES (NEW.vulnerability_id, NEW.project_id, NEW.scanner_id, report_type, severity, state, resolved_on_default_branch, NEW.uuid::uuid, NEW.location->>'image', NEW.location->'kubernetes_resource'->>'agent_id', CAST(NEW.location->'kubernetes_resource'->>'agent_id' AS bigint)) + ON CONFLICT(vulnerability_id) DO NOTHING; + RETURN NULL; + END + $$; + SQL + end + + def down + execute(<<~SQL) + CREATE OR REPLACE FUNCTION #{FUNCTION_NAME}() RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + severity smallint; + state smallint; + report_type smallint; + resolved_on_default_branch boolean; + BEGIN + IF (NEW.vulnerability_id IS NULL AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE')) THEN + RETURN NULL; + END IF; + + IF (TG_OP = 'UPDATE' AND OLD.vulnerability_id IS NOT NULL AND NEW.vulnerability_id IS NOT NULL) THEN + RETURN NULL; + END IF; + + SELECT + vulnerabilities.severity, vulnerabilities.state, vulnerabilities.report_type, vulnerabilities.resolved_on_default_branch + INTO + severity, state, report_type, resolved_on_default_branch + FROM + vulnerabilities + WHERE + vulnerabilities.id = NEW.vulnerability_id; + + INSERT INTO vulnerability_reads (vulnerability_id, project_id, scanner_id, report_type, severity, state, resolved_on_default_branch, uuid, location_image, cluster_agent_id, casted_cluster_agent_id) + VALUES (NEW.vulnerability_id, NEW.project_id, NEW.scanner_id, report_type, severity, state, resolved_on_default_branch, NEW.uuid::uuid, NEW.location->>'image', NEW.location->'kubernetes_resource'->>'agent_id', CAST(NEW.location->'kubernetes_resource'->>'agent_id' AS bigint)) + ON CONFLICT(vulnerability_id) DO NOTHING; + RETURN NULL; + END + $$; + SQL + end +end diff --git a/db/migrate/20220712031923_create_vulnerability_reads_for_an_existing_vulnerability_record.rb b/db/migrate/20220712031923_create_vulnerability_reads_for_an_existing_vulnerability_record.rb new file mode 100644 index 00000000000..68769f9e4e3 --- /dev/null +++ b/db/migrate/20220712031923_create_vulnerability_reads_for_an_existing_vulnerability_record.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class CreateVulnerabilityReadsForAnExistingVulnerabilityRecord < Gitlab::Database::Migration[2.0] + include Gitlab::Database::SchemaHelpers + + FUNCTION_NAME = 'insert_vulnerability_reads_from_vulnerability' + TRIGGER_NAME = 'trigger_insert_vulnerability_reads_from_vulnerability' + + enable_lock_retries! + + def up + execute(<<~SQL) + CREATE FUNCTION #{FUNCTION_NAME}() RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + scanner_id bigint; + uuid uuid; + location_image text; + cluster_agent_id text; + casted_cluster_agent_id bigint; + BEGIN + SELECT + v_o.scanner_id, v_o.uuid, v_o.location->>'image', v_o.location->'kubernetes_resource'->>'agent_id', CAST(v_o.location->'kubernetes_resource'->>'agent_id' AS bigint) + INTO + scanner_id, uuid, location_image, cluster_agent_id, casted_cluster_agent_id + FROM + vulnerability_occurrences v_o + WHERE + v_o.vulnerability_id = NEW.id + LIMIT 1; + + INSERT INTO vulnerability_reads (vulnerability_id, project_id, scanner_id, report_type, severity, state, resolved_on_default_branch, uuid, location_image, cluster_agent_id, casted_cluster_agent_id) + VALUES (NEW.id, NEW.project_id, scanner_id, NEW.report_type, NEW.severity, NEW.state, NEW.resolved_on_default_branch, uuid::uuid, location_image, cluster_agent_id, casted_cluster_agent_id) + ON CONFLICT(vulnerability_id) DO NOTHING; + RETURN NULL; + END + $$; + SQL + + execute(<<~SQL) + CREATE TRIGGER #{TRIGGER_NAME} + AFTER UPDATE ON vulnerabilities + FOR EACH ROW + WHEN ( + OLD.present_on_default_branch IS NOT true AND NEW.present_on_default_branch IS true + ) + EXECUTE PROCEDURE #{FUNCTION_NAME}(); + SQL + end + + def down + drop_trigger(:vulnerabilities, TRIGGER_NAME) + drop_function(FUNCTION_NAME) + end +end diff --git a/db/migrate/20220714142424_update_trigger_update_vulnerability_reads_on_vulnerability_update.rb b/db/migrate/20220714142424_update_trigger_update_vulnerability_reads_on_vulnerability_update.rb new file mode 100644 index 00000000000..00fc4ac1ed6 --- /dev/null +++ b/db/migrate/20220714142424_update_trigger_update_vulnerability_reads_on_vulnerability_update.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class UpdateTriggerUpdateVulnerabilityReadsOnVulnerabilityUpdate < Gitlab::Database::Migration[2.0] + include Gitlab::Database::SchemaHelpers + + TRIGGER_NAME = 'trigger_update_vulnerability_reads_on_vulnerability_update' + FUNCTION_NAME = 'update_vulnerability_reads_from_vulnerability' + + enable_lock_retries! + + def up + drop_trigger(:vulnerabilities, TRIGGER_NAME) + + # If the vulnerability record was not already marked as `present_on_default_branch`, + # we shouldn't try to update `vulnerability_records` since there will be no records + # anyway. + execute(<<~SQL) + CREATE TRIGGER #{TRIGGER_NAME} + AFTER UPDATE ON vulnerabilities + FOR EACH ROW + WHEN ( + OLD.present_on_default_branch IS TRUE AND + ( + OLD.severity IS DISTINCT FROM NEW.severity OR + OLD.state IS DISTINCT FROM NEW.state OR + OLD.resolved_on_default_branch IS DISTINCT FROM NEW.resolved_on_default_branch + ) + ) + EXECUTE PROCEDURE #{FUNCTION_NAME}(); + SQL + end + + def down + drop_trigger(:vulnerabilities, TRIGGER_NAME) + + execute(<<~SQL) + CREATE TRIGGER #{TRIGGER_NAME} + AFTER UPDATE ON vulnerabilities + FOR EACH ROW + WHEN ( + OLD.severity IS DISTINCT FROM NEW.severity OR + OLD.state IS DISTINCT FROM NEW.state OR + OLD.resolved_on_default_branch IS DISTINCT FROM NEW.resolved_on_default_branch + ) + EXECUTE PROCEDURE #{FUNCTION_NAME}(); + SQL + end +end diff --git a/db/schema_migrations/20220712025712 b/db/schema_migrations/20220712025712 new file mode 100644 index 00000000000..68e8a510feb --- /dev/null +++ b/db/schema_migrations/20220712025712 @@ -0,0 +1 @@ +f0bba8e67c97d6dea461d8626a07820c52e20ab6578ad40e8873ad0031a2ce62
\ No newline at end of file diff --git a/db/schema_migrations/20220712031923 b/db/schema_migrations/20220712031923 new file mode 100644 index 00000000000..1cb2b63faa8 --- /dev/null +++ b/db/schema_migrations/20220712031923 @@ -0,0 +1 @@ +db2c19f15a03a6222627875d8bd27368de43fb6485961f866de61b3017796e28
\ No newline at end of file diff --git a/db/schema_migrations/20220714142424 b/db/schema_migrations/20220714142424 new file mode 100644 index 00000000000..1a7f5e7dcaf --- /dev/null +++ b/db/schema_migrations/20220714142424 @@ -0,0 +1 @@ +42387b8524845aeb76d8b6584ffa480819f682538ca9578492eed53baa49bc09
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 7080cf1936f..cb0d4696931 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -53,6 +53,7 @@ DECLARE state smallint; report_type smallint; resolved_on_default_branch boolean; + present_on_default_branch boolean; BEGIN IF (NEW.vulnerability_id IS NULL AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE')) THEN RETURN NULL; @@ -63,14 +64,18 @@ BEGIN END IF; SELECT - vulnerabilities.severity, vulnerabilities.state, vulnerabilities.report_type, vulnerabilities.resolved_on_default_branch + vulnerabilities.severity, vulnerabilities.state, vulnerabilities.report_type, vulnerabilities.resolved_on_default_branch, vulnerabilities.present_on_default_branch INTO - severity, state, report_type, resolved_on_default_branch + severity, state, report_type, resolved_on_default_branch, present_on_default_branch FROM vulnerabilities WHERE vulnerabilities.id = NEW.vulnerability_id; + IF present_on_default_branch IS NOT true THEN + RETURN NULL; + END IF; + INSERT INTO vulnerability_reads (vulnerability_id, project_id, scanner_id, report_type, severity, state, resolved_on_default_branch, uuid, location_image, cluster_agent_id, casted_cluster_agent_id) VALUES (NEW.vulnerability_id, NEW.project_id, NEW.scanner_id, report_type, severity, state, resolved_on_default_branch, NEW.uuid::uuid, NEW.location->>'image', NEW.location->'kubernetes_resource'->>'agent_id', CAST(NEW.location->'kubernetes_resource'->>'agent_id' AS bigint)) ON CONFLICT(vulnerability_id) DO NOTHING; @@ -89,6 +94,33 @@ RETURN NULL; END $$; +CREATE FUNCTION insert_vulnerability_reads_from_vulnerability() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + scanner_id bigint; + uuid uuid; + location_image text; + cluster_agent_id text; + casted_cluster_agent_id bigint; +BEGIN + SELECT + v_o.scanner_id, v_o.uuid, v_o.location->>'image', v_o.location->'kubernetes_resource'->>'agent_id', CAST(v_o.location->'kubernetes_resource'->>'agent_id' AS bigint) + INTO + scanner_id, uuid, location_image, cluster_agent_id, casted_cluster_agent_id + FROM + vulnerability_occurrences v_o + WHERE + v_o.vulnerability_id = NEW.id + LIMIT 1; + + INSERT INTO vulnerability_reads (vulnerability_id, project_id, scanner_id, report_type, severity, state, resolved_on_default_branch, uuid, location_image, cluster_agent_id, casted_cluster_agent_id) + VALUES (NEW.id, NEW.project_id, scanner_id, NEW.report_type, NEW.severity, NEW.state, NEW.resolved_on_default_branch, uuid::uuid, location_image, cluster_agent_id, casted_cluster_agent_id) + ON CONFLICT(vulnerability_id) DO NOTHING; + RETURN NULL; +END +$$; + CREATE FUNCTION next_traversal_ids_sibling(traversal_ids integer[]) RETURNS integer[] LANGUAGE plpgsql IMMUTABLE STRICT AS $$ @@ -31645,6 +31677,8 @@ CREATE TRIGGER trigger_has_external_wiki_on_update AFTER UPDATE ON integrations CREATE TRIGGER trigger_insert_or_update_vulnerability_reads_from_occurrences AFTER INSERT OR UPDATE ON vulnerability_occurrences FOR EACH ROW EXECUTE FUNCTION insert_or_update_vulnerability_reads(); +CREATE TRIGGER trigger_insert_vulnerability_reads_from_vulnerability AFTER UPDATE ON vulnerabilities FOR EACH ROW WHEN (((old.present_on_default_branch IS NOT TRUE) AND (new.present_on_default_branch IS TRUE))) EXECUTE FUNCTION insert_vulnerability_reads_from_vulnerability(); + CREATE TRIGGER trigger_namespaces_parent_id_on_insert AFTER INSERT ON namespaces FOR EACH ROW EXECUTE FUNCTION insert_namespaces_sync_event(); CREATE TRIGGER trigger_namespaces_parent_id_on_update AFTER UPDATE ON namespaces FOR EACH ROW WHEN ((old.parent_id IS DISTINCT FROM new.parent_id)) EXECUTE FUNCTION insert_namespaces_sync_event(); @@ -31659,7 +31693,7 @@ CREATE TRIGGER trigger_update_has_issues_on_vulnerability_issue_links_update AFT CREATE TRIGGER trigger_update_location_on_vulnerability_occurrences_update AFTER UPDATE ON vulnerability_occurrences FOR EACH ROW WHEN (((new.report_type = ANY (ARRAY[2, 7])) AND (((old.location ->> 'image'::text) IS DISTINCT FROM (new.location ->> 'image'::text)) OR (((old.location -> 'kubernetes_resource'::text) ->> 'agent_id'::text) IS DISTINCT FROM ((new.location -> 'kubernetes_resource'::text) ->> 'agent_id'::text))))) EXECUTE FUNCTION update_location_from_vulnerability_occurrences(); -CREATE TRIGGER trigger_update_vulnerability_reads_on_vulnerability_update AFTER UPDATE ON vulnerabilities FOR EACH ROW WHEN (((old.severity IS DISTINCT FROM new.severity) OR (old.state IS DISTINCT FROM new.state) OR (old.resolved_on_default_branch IS DISTINCT FROM new.resolved_on_default_branch))) EXECUTE FUNCTION update_vulnerability_reads_from_vulnerability(); +CREATE TRIGGER trigger_update_vulnerability_reads_on_vulnerability_update AFTER UPDATE ON vulnerabilities FOR EACH ROW WHEN (((old.present_on_default_branch IS TRUE) AND ((old.severity IS DISTINCT FROM new.severity) OR (old.state IS DISTINCT FROM new.state) OR (old.resolved_on_default_branch IS DISTINCT FROM new.resolved_on_default_branch)))) EXECUTE FUNCTION update_vulnerability_reads_from_vulnerability(); CREATE TRIGGER users_loose_fk_trigger AFTER DELETE ON users REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION insert_into_loose_foreign_keys_deleted_records(); diff --git a/doc/security/password_storage.md b/doc/security/password_storage.md index b4c2e27c952..d3db8cbe4f6 100644 --- a/doc/security/password_storage.md +++ b/doc/security/password_storage.md @@ -7,18 +7,32 @@ type: reference # Password storage **(FREE)** +> PBKDF2 and SHA512 [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/360658) in GitLab 15.2 [with flags](../administration/feature_flags.md) named `pbkdf2_password_encryption` and `pbkdf2_password_encryption_write`. Disabled by default. + GitLab stores user passwords in a hashed format to prevent passwords from being stored as plain text. GitLab uses the [Devise](https://github.com/heartcombo/devise) authentication library to hash user passwords. Created password hashes have these attributes: -- **Hashing**: The [`bcrypt`](https://en.wikipedia.org/wiki/Bcrypt) hashing - function is used to generate the hash of the provided password. This is a - strong, industry-standard cryptographic hashing function. +- **Hashing**: + - **BCrypt**: By default, the [`bcrypt`](https://en.wikipedia.org/wiki/Bcrypt) hashing + function is used to generate the hash of the provided password. This is a + strong, industry-standard cryptographic hashing function. + - **PBKDF2 and SHA512**: Starting in GitLab 15.2, PBKDF2 and SHA512 are supported + behind the following feature flags (disabled by default): + - `pbkdf2_password_encryption` - Enables reading and comparison of PBKDF2 + SHA512 + hashed passwords and supports fallback for BCrypt hashed passwords. + - `pbkdf2_password_encryption_write` - Enables new passwords to be saved + using PBKDF2 and SHA512, and existing BCrypt passwords to be migrated when users sign in. + + FLAG: + On self-managed GitLab, by default this feature is not available. To make it available, + ask an administrator to [enable the feature flags](../administration/feature_flags.md) named `pbkdf2_password_encryption` and `pbkdf2_password_encryption_write`. + - **Stretching**: Password hashes are [stretched](https://en.wikipedia.org/wiki/Key_stretching) to harden against brute-force attacks. By default, GitLab uses a stretching - factor of 10. + factor of 10 for BCrypt and 20,000 for PBKDF2 + SHA512. - **Salting**: A [cryptographic salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) is added to each password to harden against pre-computed hash and dictionary attacks. To increase security, each salt is randomly generated for each diff --git a/doc/topics/autodevops/customize.md b/doc/topics/autodevops/customize.md index 13b83df148e..d8734ab5b13 100644 --- a/doc/topics/autodevops/customize.md +++ b/doc/topics/autodevops/customize.md @@ -243,29 +243,13 @@ See [Multiple Kubernetes clusters for Auto DevOps](multiple_clusters_auto_devops ## Customizing the Kubernetes namespace -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/27630) in GitLab 12.6. +In GitLab 14.5 and earlier, you could use `environment:kubernetes:namespace` +to specify a namespace for the environment. +However, this feature was [deprecated](https://gitlab.com/groups/gitlab-org/configure/-/epics/8), +along with certificate-based integration. -For clusters not managed by GitLab, you can customize the namespace in -`.gitlab-ci.yml` by specifying -[`environment:kubernetes:namespace`](../../ci/environments/index.md#configure-kubernetes-deployments-deprecated). -For example, the following configuration overrides the namespace used for -`production` deployments: - -```yaml -include: - - template: Auto-DevOps.gitlab-ci.yml - -production: - environment: - kubernetes: - namespace: production -``` - -When deploying to a custom namespace with Auto DevOps, the service account -provided with the cluster needs at least the `edit` role within the namespace. - -- If the service account can create namespaces, then the namespace can be created on-demand. -- Otherwise, the namespace must exist prior to deployment. +You should now use the `KUBE_NAMESPACE` environment variable and +[limit the environments it is available for](../../ci/environments/index.md#scope-environments-with-specs). ## Using components of Auto DevOps diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md index 700e0607ddc..cf864068e44 100644 --- a/doc/user/application_security/container_scanning/index.md +++ b/doc/user/application_security/container_scanning/index.md @@ -303,7 +303,9 @@ support `-fips`. Starting with GitLab 14.10, `-fips` is automatically added to `CS_ANALYZER_IMAGE` when FIPS mode is enabled in the GitLab instance. -Container scanning of images in authenticated registries is not supported when [FIPS mode](../../../development/fips_compliance.md#enable-fips-mode) is enabled. +Container scanning of images in authenticated registries is not supported when [FIPS mode](../../../development/fips_compliance.md#enable-fips-mode) +is enabled. When `CI_GITLAB_FIPS_MODE` is `"true"`, and `DOCKER_USER` or `DOCKER_PASSWORD` is set, +the analyzer exits with an error and does not perform the scan. ### Enable Container Scanning through an automatic merge request diff --git a/doc/user/packages/terraform_module_registry/index.md b/doc/user/packages/terraform_module_registry/index.md index 42c85ae9d41..2668b8b35ac 100644 --- a/doc/user/packages/terraform_module_registry/index.md +++ b/doc/user/packages/terraform_module_registry/index.md @@ -108,7 +108,7 @@ Where `<namespace>` is the [namespace](../../../user/group/index.md#namespaces) To work with Terraform modules in [GitLab CI/CD](../../../ci/index.md), you can use `CI_JOB_TOKEN` in place of the personal access token in your commands. -For example: +For example, this job uploads a new module for the `local` [system provider](https://registry.terraform.io/browse/providers) and uses the module version from the Git commit tag: ```yaml stages: @@ -121,15 +121,18 @@ upload: TERRAFORM_MODULE_DIR: ${CI_PROJECT_DIR} # The path to your Terraform module TERRAFORM_MODULE_NAME: ${CI_PROJECT_NAME} # The name of your Terraform module TERRAFORM_MODULE_SYSTEM: local # The system or provider your Terraform module targets (ex. local, aws, google) - TERRAFORM_MODULE_VERSION: ${CI_COMMIT_TAG} # The version of your Terraform module to be published to your project's registry + TERRAFORM_MODULE_VERSION: ${CI_COMMIT_TAG} # Tag commits with SemVer for the version of your Terraform module to be published script: - - tar -cvzf ${TERRAFORM_MODULE_NAME}-${TERRAFORM_MODULE_SYSTEM}-${TERRAFORM_MODULE_VERSION}.tgz -C ${TERRAFORM_MODULE_DIR} --exclude=./.git . - - 'curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file ${TERRAFORM_MODULE_NAME}-${TERRAFORM_MODULE_SYSTEM}-${TERRAFORM_MODULE_VERSION}.tgz ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/terraform/modules/${TERRAFORM_MODULE_NAME}/${TERRAFORM_MODULE_SYSTEM}/${TERRAFORM_MODULE_VERSION}/file' + - TERRAFORM_MODULE_NAME=$(echo "${TERRAFORM_MODULE_NAME}" | tr " _" -) # module-name must not have spaces or underscores, so translate them to hyphens + - tar -vczf ${TERRAFORM_MODULE_NAME}-${TERRAFORM_MODULE_SYSTEM}-${TERRAFORM_MODULE_VERSION}.tgz -C ${TERRAFORM_MODULE_DIR} --exclude=./.git . + - 'curl --location --header "JOB-TOKEN: ${CI_JOB_TOKEN}" + --upload-file ${TERRAFORM_MODULE_NAME}-${TERRAFORM_MODULE_SYSTEM}-${TERRAFORM_MODULE_VERSION}.tgz + ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/terraform/modules/${TERRAFORM_MODULE_NAME}/${TERRAFORM_MODULE_SYSTEM}/${TERRAFORM_MODULE_VERSION}/file' rules: - if: $CI_COMMIT_TAG ``` -To trigger this upload job, add a Git tag to your commit. The `rules:if: $CI_COMMIT_TAG` defines this so that not every commit to your repo triggers the upload. +To trigger this upload job, add a Git tag to your commit. Ensure the tag follows the [Semantic Versioning Specification](https://semver.org/) that Terraform requires. The `rules:if: $CI_COMMIT_TAG` ensures that only tagged commits to your repo trigger the module upload job. For other ways to control jobs in your CI/CD pipeline, refer to the [`.gitlab-ci.yml`](../../../ci/yaml/index.md) keyword reference. ## Example projects diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ece81a08f5a..2f11794b514 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5802,6 +5802,9 @@ msgstr "" msgid "BambooService|The user with API access to the Bamboo server." msgstr "" +msgid "Banned" +msgstr "" + msgid "Banner message" msgstr "" @@ -27707,6 +27710,9 @@ msgstr "" msgid "PackageRegistry|Error publishing" msgstr "" +msgid "PackageRegistry|Examples of assets include .pom & .jar files" +msgstr "" + msgid "PackageRegistry|Failed to load the package data" msgstr "" @@ -27764,6 +27770,9 @@ msgstr "" msgid "PackageRegistry|License information located at %{link}" msgstr "" +msgid "PackageRegistry|Manage storage used by package assets" +msgstr "" + msgid "PackageRegistry|Manually Published" msgstr "" @@ -27782,6 +27791,9 @@ msgstr "" msgid "PackageRegistry|NuGet Command" msgstr "" +msgid "PackageRegistry|Number of duplicate assets to keep" +msgstr "" + msgid "PackageRegistry|Package Registry" msgstr "" @@ -27907,6 +27919,9 @@ msgstr "" msgid "PackageRegistry|Unable to load package" msgstr "" +msgid "PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets." +msgstr "" + msgid "PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb index c58865fff03..04777bd7f01 100644 --- a/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb +++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb @@ -37,6 +37,8 @@ module QA end before do + Runtime::Feature.disable(:simulate_pipeline) + Flow::Login.sign_in project.visit! Page::Project::Menu.perform(&:go_to_pipeline_editor) diff --git a/spec/channels/awareness_channel_spec.rb b/spec/channels/awareness_channel_spec.rb new file mode 100644 index 00000000000..8d6dc36f6bd --- /dev/null +++ b/spec/channels/awareness_channel_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AwarenessChannel, :clean_gitlab_redis_shared_state, type: :channel do + before do + stub_action_cable_connection(current_user: user) + end + + context "with user" do + let(:user) { create(:user) } + + describe "when no path parameter given" do + it "rejects subscription" do + subscribe path: nil + + expect(subscription).to be_rejected + end + end + + describe "with valid path parameter" do + it "successfully subscribes" do + subscribe path: "/test" + + session = AwarenessSession.for("/test") + + expect(subscription).to be_confirmed + # check if we can use session object instead + expect(subscription).to have_stream_from("awareness:#{session.to_param}") + end + + it "broadcasts set of collaborators when subscribing" do + session = AwarenessSession.for("/test") + + freeze_time do + collaborator = { + id: user.id, + name: user.name, + avatar_url: user.avatar_url(size: 36), + last_activity: Time.zone.now, + last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( + Time.zone.now, Time.zone.now + ) + } + + expect do + subscribe path: "/test" + end.to have_broadcasted_to("awareness:#{session.to_param}") + .with(collaborators: [collaborator]) + end + end + + it "transmits payload when user is touched" do + subscribe path: "/test" + + perform :touch + + expect(transmissions.size).to be 1 + end + + it "unsubscribes from channel" do + subscribe path: "/test" + session = AwarenessSession.for("/test") + + expect { subscription.unsubscribe_from_channel } + .to change { session.size}.by(-1) + end + end + end + + context "with guest" do + let(:user) { nil } + + it "rejects subscription" do + subscribe path: "/test" + + expect(subscription).to be_rejected + end + end +end diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index 023601b0b1e..e07a5d09405 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -49,7 +49,7 @@ RSpec.describe 'Project navbar' do stub_config(pages: { enabled: true }) insert_after_sub_nav_item( - _('CI/CD'), + _('Packages & Registries'), within: _('Settings'), new_sub_nav_item_name: _('Pages') ) @@ -60,18 +60,22 @@ RSpec.describe 'Project navbar' do it_behaves_like 'verified navigation bar' end + context 'when package registry is available' do + before do + stub_config(packages: { enabled: true }) + + visit project_path(project) + end + + it_behaves_like 'verified navigation bar' + end + context 'when container registry is available' do before do stub_config(registry: { enabled: true }) insert_container_nav - insert_after_sub_nav_item( - _('CI/CD'), - within: _('Settings'), - new_sub_nav_item_name: _('Packages & Registries') - ) - visit project_path(project) end diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb index ff28d59ed08..9468540736f 100644 --- a/spec/features/projects/settings/registry_settings_spec.rb +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration policy', :js do +RSpec.describe 'Project > Settings > Packages & Registries > Container registry tag expiration policy', :js do let_it_be(:user) { create(:user) } let_it_be(:project, reload: true) { create(:project, namespace: user.namespace) } @@ -23,14 +23,15 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p it 'shows available section' do subject - settings_block = find('[data-testid="registry-settings-app"]') + settings_block = find('[data-testid="container-expiration-policy-project-settings"]') expect(settings_block).to have_text 'Clean up image tags' end it 'saves cleanup policy submit the form' do subject - within '[data-testid="registry-settings-app"]' do + within '[data-testid="container-expiration-policy-project-settings"]' do + click_button('Expand') select('Every day', from: 'Run cleanup') select('50 tags per image name', from: 'Keep the most recent:') fill_in('Keep tags matching:', with: 'stable') @@ -48,7 +49,8 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p it 'does not save cleanup policy submit form with invalid regex' do subject - within '[data-testid="registry-settings-app"]' do + within '[data-testid="container-expiration-policy-project-settings"]' do + click_button('Expand') fill_in('Remove tags matching:', with: '*-production') submit_button = find('[data-testid="save-button"') @@ -73,7 +75,8 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p it 'displays the related section' do subject - within '[data-testid="registry-settings-app"]' do + within '[data-testid="container-expiration-policy-project-settings"]' do + click_button('Expand') expect(find('[data-testid="enable-toggle"]')).to have_content('Disabled - Tags will not be automatically deleted.') end end @@ -87,7 +90,8 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p it 'does not display the related section' do subject - within '[data-testid="registry-settings-app"]' do + within '[data-testid="container-expiration-policy-project-settings"]' do + click_button('Expand') expect(find('.gl-alert-title')).to have_content('Cleanup policy for tags is disabled') end end @@ -100,7 +104,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p it 'does not exists' do subject - expect(page).not_to have_selector('[data-testid="registry-settings-app"]') + expect(page).not_to have_selector('[data-testid="container-expiration-policy-project-settings"]') end end @@ -110,7 +114,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p it 'does not exists' do subject - expect(page).not_to have_selector('[data-testid="registry-settings-app"]') + expect(page).not_to have_selector('[data-testid="container-expiration-policy-project-settings"]') end end end diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap index faa313118f3..108d9478788 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap @@ -4,6 +4,7 @@ exports[`Container Expiration Policy Settings Form Cadence matches snapshot 1`] <expiration-dropdown-stub class="gl-mr-7 gl-mb-0!" data-testid="cadence-dropdown" + description="" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" label="Run cleanup:" name="cadence" @@ -22,6 +23,7 @@ exports[`Container Expiration Policy Settings Form Enable matches snapshot 1`] = exports[`Container Expiration Policy Settings Form Keep N matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="keep-n-dropdown" + description="" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Keep the most recent:" name="keep-n" @@ -44,6 +46,7 @@ exports[`Container Expiration Policy Settings Form Keep Regex matches snapshot 1 exports[`Container Expiration Policy Settings Form OlderThan matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="older-than-dropdown" + description="" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Remove tags older than:" name="older-than" diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js index aa3506771fa..d83c717da6a 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js @@ -43,11 +43,6 @@ describe('Container expiration policy project settings', () => { GlSprintf, SettingsBlock, }, - mocks: { - $toast: { - show: jest.fn(), - }, - }, provide, ...config, }); @@ -98,7 +93,7 @@ describe('Container expiration policy project settings', () => { await waitForPromises(); expect(findFormComponent().exists()).toBe(true); - expect(findSettingsBlock().props('collapsible')).toBe(false); + expect(findSettingsBlock().exists()).toBe(true); }); describe('the form is disabled', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js index 5c9ade7f785..8b99ac6b06c 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js @@ -16,6 +16,7 @@ describe('ExpirationDropdown', () => { const findFormSelect = () => wrapper.find(GlFormSelect); const findFormGroup = () => wrapper.find(GlFormGroup); + const findDescription = () => wrapper.find('[data-testid="description"]'); const findOptions = () => wrapper.findAll('[data-testid="option"]'); const mountComponent = (props) => { @@ -47,6 +48,14 @@ describe('ExpirationDropdown', () => { expect(findOptions()).toHaveLength(defaultProps.formOptions.length); }); + + it('renders the description if passed', () => { + mountComponent({ + description: 'test description', + }); + + expect(findDescription().html()).toContain('test description'); + }); }); describe('model', () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js new file mode 100644 index 00000000000..86f45d78bae --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js @@ -0,0 +1,267 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, + KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL, + KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; +import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql'; +import Tracking from '~/tracking'; +import { packagesCleanupPolicyPayload, packagesCleanupPolicyMutationPayload } from '../mock_data'; + +Vue.use(VueApollo); + +describe('Packages Cleanup Policy Settings Form', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const { + data: { + project: { packagesCleanupPolicy }, + }, + } = packagesCleanupPolicyPayload(); + + const defaultProps = { + value: { ...packagesCleanupPolicy }, + }; + + const trackingPayload = { + label: 'packages_cleanup_policies', + }; + + const findForm = () => wrapper.find({ ref: 'form-element' }); + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findKeepNDuplicatedPackageFilesDropdown = () => + wrapper.findByTestId('keep-n-duplicated-package-files-dropdown'); + + const submitForm = async () => { + findForm().trigger('submit'); + return waitForPromises(); + }; + + const mountComponent = ({ + props = defaultProps, + data, + config, + provide = defaultProvidedValues, + } = {}) => { + wrapper = shallowMountExtended(component, { + stubs: { + GlLoadingIcon, + }, + propsData: { ...props }, + provide, + data() { + return { + ...data, + }; + }, + mocks: { + $toast: { + show: jest.fn(), + }, + }, + ...config, + }); + }; + + const mountComponentWithApollo = ({ + provide = defaultProvidedValues, + mutationResolver, + queryPayload = packagesCleanupPolicyPayload(), + } = {}) => { + const requestHandlers = [[updatePackagesCleanupPolicyMutation, mutationResolver]]; + + fakeApollo = createMockApollo(requestHandlers); + + const { + data: { + project: { packagesCleanupPolicy: value }, + }, + } = queryPayload; + + mountComponent({ + provide, + props: { + ...defaultProps, + value, + }, + config: { + apolloProvider: fakeApollo, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('keepNDuplicatedPackageFiles', () => { + it('renders dropdown', () => { + mountComponent(); + + const element = findKeepNDuplicatedPackageFilesDropdown(); + + expect(element.exists()).toBe(true); + expect(element.props('label')).toMatchInterpolatedText(KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL); + expect(element.props('description')).toEqual(KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION); + }); + + it('input event triggers a model update', () => { + mountComponent(); + + findKeepNDuplicatedPackageFilesDropdown().vm.$emit('input', 'foo'); + expect(wrapper.emitted('input')[0][0]).toMatchObject({ + keepNDuplicatedPackageFiles: 'foo', + }); + }); + + it('shows the default option when none are selected', () => { + mountComponent({ props: { value: {} } }); + expect(findKeepNDuplicatedPackageFilesDropdown().props('value')).toEqual('ALL_PACKAGE_FILES'); + }); + + it.each` + isLoading | mutationLoading + ${true} | ${false} + ${true} | ${true} + ${false} | ${true} + `( + 'is disabled when is loading is $isLoading and mutationLoading is $mutationLoading', + ({ isLoading, mutationLoading }) => { + mountComponent({ + props: { isLoading, value: {} }, + data: { mutationLoading }, + }); + expect(findKeepNDuplicatedPackageFilesDropdown().props('disabled')).toEqual(true); + }, + ); + + it('has the correct formOptions', () => { + mountComponent(); + expect(findKeepNDuplicatedPackageFilesDropdown().props('formOptions')).toEqual( + wrapper.vm.$options.formOptions.keepNDuplicatedPackageFiles, + ); + }); + }); + + describe('form', () => { + describe('actions', () => { + describe('submit button', () => { + it('has type submit', () => { + mountComponent(); + + expect(findSaveButton().attributes('type')).toBe('submit'); + }); + + it.each` + isLoading | mutationLoading | disabled + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is disabled', + ({ isLoading, mutationLoading, disabled }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('disabled')).toBe(disabled); + expect(findKeepNDuplicatedPackageFilesDropdown().props('disabled')).toBe(disabled); + }, + ); + + it.each` + isLoading | mutationLoading | showLoading + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown', + ({ isLoading, mutationLoading, showLoading }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('loading')).toBe(showLoading); + }, + ); + }); + }); + + describe('form submit event', () => { + it('dispatches the correct apollo mutation', () => { + const mutationResolver = jest + .fn() + .mockResolvedValue(packagesCleanupPolicyMutationPayload()); + mountComponentWithApollo({ + mutationResolver, + }); + + findForm().trigger('submit'); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { + keepNDuplicatedPackageFiles: 'ALL_PACKAGE_FILES', + projectPath: 'path', + }, + }); + }); + + it('tracks the submit event', () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + + expect(Tracking.event).toHaveBeenCalledWith( + undefined, + 'submit_packages_cleanup_form', + trackingPayload, + ); + }); + + it('show a success toast when submit succeed', async () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()), + }); + + await submitForm(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE); + }); + + describe('when submit fails', () => { + it('shows an error', async () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockRejectedValue(packagesCleanupPolicyMutationPayload()), + }); + + await submitForm(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js new file mode 100644 index 00000000000..6dfeeca6862 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js @@ -0,0 +1,81 @@ +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; +import PackagesCleanupPolicyForm from '~/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue'; +import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/packages_and_registries/settings/project/constants'; +import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; + +import { packagesCleanupPolicyPayload, packagesCleanupPolicyData } from '../mock_data'; + +Vue.use(VueApollo); + +describe('Packages cleanup policy project settings', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findFormComponent = () => wrapper.findComponent(PackagesCleanupPolicyForm); + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + + const mountComponent = (provide = defaultProvidedValues, config) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + SettingsBlock, + }, + provide, + ...config, + }); + }; + + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + const requestHandlers = [[packagesCleanupPolicyQuery, resolver]]; + + fakeApollo = createMockApollo(requestHandlers); + mountComponent(provide, { + apolloProvider: fakeApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + it('renders the setting form', async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(packagesCleanupPolicyPayload()), + }); + await waitForPromises(); + + expect(findFormComponent().exists()).toBe(true); + expect(findFormComponent().props('value')).toEqual(packagesCleanupPolicyData); + expect(findSettingsBlock().exists()).toBe(true); + }); + + describe('fetchSettingsError', () => { + beforeEach(async () => { + mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + await waitForPromises(); + }); + + it('the form is hidden', () => { + expect(findFormComponent().exists()).toBe(false); + }); + + it('shows an alert', () => { + expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 337991dfae0..f576bc79eae 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -1,19 +1,41 @@ import { shallowMount } from '@vue/test-utils'; import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; +import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; describe('Registry Settings app', () => { let wrapper; + const findContainerExpirationPolicy = () => wrapper.find(ContainerExpirationPolicy); + const findPackagesCleanupPolicy = () => wrapper.find(PackagesCleanupPolicy); afterEach(() => { wrapper.destroy(); wrapper = null; }); - it('renders container expiration policy component', () => { - wrapper = shallowMount(component); + const mountComponent = (provide) => { + wrapper = shallowMount(component, { + provide, + }); + }; - expect(findContainerExpirationPolicy().exists()).toBe(true); - }); + it.each` + showContainerRegistrySettings | showPackageRegistrySettings + ${true} | ${false} + ${true} | ${true} + ${false} | ${true} + ${false} | ${false} + `( + 'container expiration policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', + ({ showContainerRegistrySettings, showPackageRegistrySettings }) => { + mountComponent({ + showContainerRegistrySettings, + showPackageRegistrySettings, + }); + + expect(findContainerExpirationPolicy().exists()).toBe(showContainerRegistrySettings); + expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings); + }, + ); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index 33406c98f4b..d4b6c66ddeb 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -40,3 +40,33 @@ export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) }, }, }); + +export const packagesCleanupPolicyData = { + keepNDuplicatedPackageFiles: 'ALL_PACKAGE_FILES', + nextRunAt: '2020-11-19T07:37:03.941Z', +}; + +export const packagesCleanupPolicyPayload = (override) => ({ + data: { + project: { + id: '1', + packagesCleanupPolicy: { + __typename: 'PackagesCleanupPolicy', + ...packagesCleanupPolicyData, + ...override, + }, + }, + }, +}); + +export const packagesCleanupPolicyMutationPayload = ({ override, errors = [] } = {}) => ({ + data: { + updatePackagesCleanupPolicy: { + packagesCleanupPolicy: { + ...packagesCleanupPolicyData, + ...override, + }, + errors, + }, + }, +}); diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb index d308df3a017..89c26c21338 100644 --- a/spec/helpers/groups/group_members_helper_spec.rb +++ b/spec/helpers/groups/group_members_helper_spec.rb @@ -44,6 +44,7 @@ RSpec.describe Groups::GroupMembersHelper do members: present_members(members_collection), invited: present_members(invited), access_requests: present_members(access_requests), + banned: [], include_relations: [:inherited, :direct], search: nil ) @@ -117,6 +118,7 @@ RSpec.describe Groups::GroupMembersHelper do members: present_members(members_collection), invited: present_members(invited), access_requests: present_members(access_requests), + banned: [], include_relations: include_relations, search: nil ) diff --git a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb index a9fe293dd69..f2507a24b10 100644 --- a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb +++ b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb @@ -31,16 +31,14 @@ RSpec.describe Gitlab::Ci::RunnerUpgradeCheck do end context 'with available_runner_releases configured' do - let(:runner_releases_double) { instance_double(Gitlab::Ci::RunnerReleases) } - before do - allow(Gitlab::Ci::RunnerReleases).to receive(:instance).and_return(runner_releases_double) - allow(runner_releases_double).to receive(:releases) - .and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) }) - allow(runner_releases_double).to receive(:releases_by_minor) - .and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) } - .group_by(&:without_patch) - .transform_values(&:max)) + url = ::Gitlab::CurrentSettings.current_application_settings.public_runner_releases_url + + WebMock.stub_request(:get, url).to_return( + body: available_runner_releases.map { |v| { name: v } }.to_json, + status: 200, + headers: { 'Content-Type' => 'application/json' } + ) end context 'with no available runner releases' do diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb index d6136dddf40..f41f7a01d88 100644 --- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb @@ -135,18 +135,20 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do describe 'Packages & Registries' do let(:item_id) { :packages_and_registries } + let(:packages_enabled) { false } before do stub_container_registry_config(enabled: container_enabled) + stub_config(packages: { enabled: packages_enabled }) end - describe 'when config registry setting is disabled' do + describe 'when container registry setting is disabled' do let(:container_enabled) { false } specify { is_expected.to be_nil } end - describe 'when config registry setting is enabled' do + describe 'when container registry setting is enabled' do let(:container_enabled) { true } specify { is_expected.not_to be_nil } @@ -157,6 +159,19 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do specify { is_expected.to be_nil } end end + + describe 'when package registry setting is enabled' do + let(:container_enabled) { false } + let(:packages_enabled) { true } + + specify { is_expected.not_to be_nil } + + describe 'when the user does not have access' do + let(:user) { nil } + + specify { is_expected.to be_nil } + end + end end describe 'Usage Quotas' do diff --git a/spec/models/awareness_session_spec.rb b/spec/models/awareness_session_spec.rb index 4dace7cb8de..854ce5957f7 100644 --- a/spec/models/awareness_session_spec.rb +++ b/spec/models/awareness_session_spec.rb @@ -2,14 +2,24 @@ require 'spec_helper' -RSpec.describe AwarenessSession do +RSpec.describe AwarenessSession, :clean_gitlab_redis_shared_state do subject { AwarenessSession.for(session_id) } let!(:user) { create(:user) } let(:session_id) { 1 } - after do - redis_shared_state_cleanup! + describe "when initiating a session" do + it "provides a string representation of the model instance" do + expected = "awareness_session=6b86b273ff34fce" + + expect(subject.to_s).to eql(expected) + end + + it "provides a parameterized version of the session identifier" do + expected = "6b86b273ff34fce" + + expect(subject.to_param).to eql(expected) + end end describe "when a user joins a session" do @@ -103,6 +113,26 @@ RSpec.describe AwarenessSession do expect(ttl_user).to be > 0 end end + + it "fetches user(s) from database" do + subject.join(user) + + expect(subject.users.first).to eql(user) + end + + it "fetches and filters online user(s) from database" do + subject.join(user) + + travel 2.hours do + subject.join(user2) + + online_users = subject.online_users_with_last_activity + online_user, _ = online_users.first + + expect(online_users.size).to be 1 + expect(online_user).to eql(user2) + end + end end describe "when a user leaves a session" do diff --git a/spec/models/concerns/awareness_spec.rb b/spec/models/concerns/awareness_spec.rb index 9119fe2c458..67acacc7bb1 100644 --- a/spec/models/concerns/awareness_spec.rb +++ b/spec/models/concerns/awareness_spec.rb @@ -2,15 +2,11 @@ require 'spec_helper' -RSpec.describe Awareness do +RSpec.describe Awareness, :clean_gitlab_redis_shared_state do subject { create(:user) } let(:session) { AwarenessSession.for(1) } - after do - redis_shared_state_cleanup! - end - describe "when joining a session" do it "increases the number of sessions" do expect { subject.join(session) } diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index de36b755e88..c041c72a0be 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -1480,43 +1480,142 @@ RSpec.describe ProjectPolicy do end describe 'view_package_registry_project_settings' do - context 'with registry enabled' do + context 'with packages disabled and' do before do - stub_config(registry: { enabled: true }) + stub_config(packages: { enabled: false }) end - context 'with an admin user' do - let(:current_user) { admin } + context 'with registry enabled' do + before do + stub_config(registry: { enabled: true }) + end - context 'when admin mode enabled', :enable_admin_mode do - it { is_expected.to be_allowed(:view_package_registry_project_settings) } + context 'with an admin user' do + let(:current_user) { admin } + + context 'when admin mode enabled', :enable_admin_mode do + it { is_expected.to be_allowed(:view_package_registry_project_settings) } + end + + context 'when admin mode disabled' do + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end end - context 'when admin mode disabled' do - it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + %i[owner maintainer].each do |role| + context "with #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_allowed(:view_package_registry_project_settings) } + end + end + + %i[developer reporter guest non_member anonymous].each do |role| + context "with #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end end end - %i[owner maintainer].each do |role| - context "with #{role}" do - let(:current_user) { public_send(role) } + context 'with registry disabled' do + before do + stub_config(registry: { enabled: false }) + end + + context 'with admin user' do + let(:current_user) { admin } + + context 'when admin mode enabled', :enable_admin_mode do + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end + + context 'when admin mode disabled' do + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end + end - it { is_expected.to be_allowed(:view_package_registry_project_settings) } + %i[owner maintainer developer reporter guest non_member anonymous].each do |role| + context "with #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end end end + end - %i[developer reporter guest non_member anonymous].each do |role| - context "with #{role}" do - let(:current_user) { public_send(role) } + context 'with registry disabled and' do + before do + stub_config(registry: { enabled: false }) + end - it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + context 'with packages enabled' do + before do + stub_config(packages: { enabled: true }) + end + + context 'with an admin user' do + let(:current_user) { admin } + + context 'when admin mode enabled', :enable_admin_mode do + it { is_expected.to be_allowed(:view_package_registry_project_settings) } + end + + context 'when admin mode disabled' do + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end + end + + %i[owner maintainer].each do |role| + context "with #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_allowed(:view_package_registry_project_settings) } + end + end + + %i[developer reporter guest non_member anonymous].each do |role| + context "with #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end + end + end + + context 'with packages disabled' do + before do + stub_config(packages: { enabled: false }) + end + + context 'with admin user' do + let(:current_user) { admin } + + context 'when admin mode enabled', :enable_admin_mode do + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end + + context 'when admin mode disabled' do + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end + end + + %i[owner maintainer developer reporter guest non_member anonymous].each do |role| + context "with #{role}" do + let(:current_user) { public_send(role) } + + it { is_expected.to be_disallowed(:view_package_registry_project_settings) } + end end end end - context 'with registry disabled' do + context 'with registry & packages both disabled' do before do stub_config(registry: { enabled: false }) + stub_config(packages: { enabled: false }) end context 'with admin user' do diff --git a/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb b/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb index dc4f76b723f..f8313eaab90 100644 --- a/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb +++ b/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb @@ -10,101 +10,131 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute' create(:ci_runner_version, version: '14.0.1', status: :not_available) end - before do - stub_const('Ci::Runners::ReconcileExistingRunnerVersionsService::VERSION_BATCH_SIZE', 1) - - allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) - .to receive(:check_runner_upgrade_status) - .and_return({ recommended: ::Gitlab::VersionInfo.new(14, 0, 2) }) - end - - context 'with runner with new version' do - let!(:runner_14_0_2) { create(:ci_runner, version: '14.0.2') } - let!(:runner_version_14_0_0) { create(:ci_runner_version, version: '14.0.0', status: :not_available) } - let!(:runner_14_0_0) { create(:ci_runner, version: '14.0.0') } - + context 'with RunnerUpgradeCheck recommending 14.0.2' do before do + stub_const('Ci::Runners::ReconcileExistingRunnerVersionsService::VERSION_BATCH_SIZE', 1) + allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .with('14.0.2') - .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) }) - .once + .and_return({ recommended: ::Gitlab::VersionInfo.new(14, 0, 2) }) end - it 'creates and updates expected ci_runner_versions entries', :aggregate_failures do - expect(Ci::RunnerVersion).to receive(:insert_all) - .ordered - .with([{ version: '14.0.2' }], anything) - .once - .and_call_original - - result = nil - expect { result = execute } - .to change { runner_version_14_0_0.reload.status }.from('not_available').to('recommended') - .and change { runner_version_14_0_1.reload.status }.from('not_available').to('recommended') - .and change { ::Ci::RunnerVersion.find_by(version: '14.0.2')&.status }.from(nil).to('not_available') - - expect(result).to eq({ - status: :success, - total_inserted: 1, # 14.0.2 is inserted - total_updated: 3, # 14.0.0, 14.0.1 are updated, and newly inserted 14.0.2's status is calculated - total_deleted: 0 - }) + context 'with runner with new version' do + let!(:runner_14_0_2) { create(:ci_runner, version: '14.0.2') } + let!(:runner_version_14_0_0) { create(:ci_runner_version, version: '14.0.0', status: :not_available) } + let!(:runner_14_0_0) { create(:ci_runner, version: '14.0.0') } + + before do + allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .with('14.0.2') + .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) }) + .once + end + + it 'creates and updates expected ci_runner_versions entries', :aggregate_failures do + expect(Ci::RunnerVersion).to receive(:insert_all) + .ordered + .with([{ version: '14.0.2' }], anything) + .once + .and_call_original + + result = nil + expect { result = execute } + .to change { runner_version_14_0_0.reload.status }.from('not_available').to('recommended') + .and change { runner_version_14_0_1.reload.status }.from('not_available').to('recommended') + .and change { ::Ci::RunnerVersion.find_by(version: '14.0.2')&.status }.from(nil).to('not_available') + + expect(result).to eq({ + status: :success, + total_inserted: 1, # 14.0.2 is inserted + total_updated: 3, # 14.0.0, 14.0.1 are updated, and newly inserted 14.0.2's status is calculated + total_deleted: 0 + }) + end end - end - - context 'with orphan ci_runner_version' do - let!(:runner_version_14_0_2) { create(:ci_runner_version, version: '14.0.2', status: :not_available) } - before do - allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) - .to receive(:check_runner_upgrade_status) - .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) }) + context 'with orphan ci_runner_version' do + let!(:runner_version_14_0_2) { create(:ci_runner_version, version: '14.0.2', status: :not_available) } + + before do + allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) }) + end + + it 'deletes orphan ci_runner_versions entry', :aggregate_failures do + result = nil + expect { result = execute } + .to change { ::Ci::RunnerVersion.find_by_version('14.0.2')&.status }.from('not_available').to(nil) + .and not_change { runner_version_14_0_1.reload.status }.from('not_available') + + expect(result).to eq({ + status: :success, + total_inserted: 0, + total_updated: 0, + total_deleted: 1 # 14.0.2 is deleted + }) + end end - it 'deletes orphan ci_runner_versions entry', :aggregate_failures do - result = nil - expect { result = execute } - .to change { ::Ci::RunnerVersion.find_by_version('14.0.2')&.status }.from('not_available').to(nil) - .and not_change { runner_version_14_0_1.reload.status }.from('not_available') - - expect(result).to eq({ - status: :success, - total_inserted: 0, - total_updated: 0, - total_deleted: 1 # 14.0.2 is deleted - }) + context 'with no runner version changes' do + before do + allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 1) }) + end + + it 'does not modify ci_runner_versions entries', :aggregate_failures do + result = nil + expect { result = execute }.not_to change { runner_version_14_0_1.reload.status }.from('not_available') + + expect(result).to eq({ + status: :success, + total_inserted: 0, + total_updated: 0, + total_deleted: 0 + }) + end end - end - context 'with no runner version changes' do - before do - allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) - .to receive(:check_runner_upgrade_status) - .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 1) }) + context 'with failing version check' do + before do + allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) + .to receive(:check_runner_upgrade_status) + .and_return({ error: ::Gitlab::VersionInfo.new(14, 0, 1) }) + end + + it 'makes no changes to ci_runner_versions', :aggregate_failures do + result = nil + expect { result = execute }.not_to change { runner_version_14_0_1.reload.status }.from('not_available') + + expect(result).to eq({ + status: :success, + total_inserted: 0, + total_updated: 0, + total_deleted: 0 + }) + end end + end - it 'does not modify ci_runner_versions entries', :aggregate_failures do - result = nil - expect { result = execute }.not_to change { runner_version_14_0_1.reload.status }.from('not_available') - - expect(result).to eq({ - status: :success, - total_inserted: 0, - total_updated: 0, - total_deleted: 0 - }) + context 'integration testing with Gitlab::Ci::RunnerUpgradeCheck' do + let(:available_runner_releases) do + %w[14.0.0 14.0.1] end - end - context 'with failing version check' do before do - allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) - .to receive(:check_runner_upgrade_status) - .and_return({ error: ::Gitlab::VersionInfo.new(14, 0, 1) }) + url = ::Gitlab::CurrentSettings.current_application_settings.public_runner_releases_url + + WebMock.stub_request(:get, url).to_return( + body: available_runner_releases.map { |v| { name: v } }.to_json, + status: 200, + headers: { 'Content-Type' => 'application/json' } + ) end - it 'makes no changes to ci_runner_versions', :aggregate_failures do + it 'does not modify ci_runner_versions entries', :aggregate_failures do result = nil expect { result = execute }.not_to change { runner_version_14_0_1.reload.status }.from('not_available') diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index b523af385f4..6c2ed79b343 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -110,6 +110,7 @@ RSpec.shared_context 'project navbar structure' do _('Access Tokens'), _('Repository'), _('CI/CD'), + _('Packages & Registries'), _('Monitor'), s_('UsageQuota|Usage Quotas') ] diff --git a/spec/views/groups/group_members/index.html.haml_spec.rb b/spec/views/groups/group_members/index.html.haml_spec.rb index 40d4c9d33c9..2d7d50555d6 100644 --- a/spec/views/groups/group_members/index.html.haml_spec.rb +++ b/spec/views/groups/group_members/index.html.haml_spec.rb @@ -37,4 +37,16 @@ RSpec.describe 'groups/group_members/index', :aggregate_failures do expect(rendered).not_to have_content('You can invite a new member') end end + + context 'when @banned is nil' do + before do + assign(:banned, nil) + end + + it 'calls group_members_app_data with { banned: [] }' do + expect(view).to receive(:group_members_app_data).with(group, a_hash_including(banned: [])) + + render + end + end end diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index e3c2466f807..9ae3f814679 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -909,8 +909,11 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end describe 'Packages & Registries' do + let(:packages_enabled) { false } + before do stub_container_registry_config(enabled: registry_enabled) + stub_config(packages: { enabled: packages_enabled }) end context 'when registry is enabled' do @@ -932,6 +935,17 @@ RSpec.describe 'layouts/nav/sidebar/_project' do expect(rendered).not_to have_link('Packages & Registries', href: project_settings_packages_and_registries_path(project)) end end + + context 'when packages config is enabled' do + let(:registry_enabled) { false } + let(:packages_enabled) { true } + + it 'has a link to the Packages & Registries settings' do + render + + expect(rendered).to have_link('Packages & Registries', href: project_settings_packages_and_registries_path(project)) + end + end end describe 'Usage Quotas' do |