Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-19 18:09:21 +00:00
parent 9c8e8b5ffc
commit f602da84d1
59 changed files with 1602 additions and 234 deletions

View File

@ -1 +1 @@
76dabc8174f7978025f48adcfab0a19c85416531 ca638e23ca921cf2f2f3cdc8a6ff033af667170b

View File

@ -1,40 +1,48 @@
<script> <script>
import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui'; import { GlTabs, GlTab, GlBadge, GlButton } from '@gitlab/ui';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import { queryToObject } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; 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'; import MembersApp from './app.vue';
const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0; 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 { export default {
name: 'MembersTabs', name: 'MembersTabs',
ACTIVE_TAB_QUERY_PARAM_NAME, ACTIVE_TAB_QUERY_PARAM_NAME,
TABS: [ 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,
},
],
components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton }, components: { MembersApp, GlTabs, GlTab, GlBadge, GlButton },
inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'], inject: ['canManageMembers', 'canExportMembers', 'exportCsvPath'],
data() { data() {
@ -43,20 +51,17 @@ export default {
}; };
}, },
computed: { computed: {
...mapState({ ...mapState(
userCount(state) { Object.values(MEMBER_TYPES).reduce((getters, memberType) => {
return countComputed(state, MEMBER_TYPES.user); return {
}, ...getters,
groupCount(state) { // eslint-disable-next-line @gitlab/require-i18n-strings
return countComputed(state, MEMBER_TYPES.group); [`${memberType}Count`](state) {
}, return countComputed(state, memberType);
inviteCount(state) { },
return countComputed(state, MEMBER_TYPES.invite); };
}, }, {}),
accessRequestCount(state) { ),
return countComputed(state, MEMBER_TYPES.accessRequest);
},
}),
urlParams() { urlParams() {
return Object.keys(queryToObject(window.location.search, { gatherArrays: true })); return Object.keys(queryToObject(window.location.search, { gatherArrays: true }));
}, },

View File

@ -6,7 +6,13 @@ import UserAvatar from '../avatars/user_avatar.vue';
export default { export default {
name: 'MemberAvatar', name: 'MemberAvatar',
components: { UserAvatar, InviteAvatar, GroupAvatar, AccessRequestAvatar: UserAvatar }, components: {
UserAvatar,
InviteAvatar,
GroupAvatar,
AccessRequestAvatar: UserAvatar,
BannedAvatar: UserAvatar,
},
props: { props: {
memberType: { memberType: {
type: String, type: String,

View File

@ -1,5 +1,5 @@
<script> <script>
import { MEMBER_TYPES } from '../../constants'; import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
import { import {
isGroup, isGroup,
isDirectMember, isDirectMember,

View File

@ -3,6 +3,12 @@ import { GlFilteredSearchToken } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; 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_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source'; export const FIELD_KEY_SOURCE = 'source';
export const FIELD_KEY_GRANTED = 'granted'; export const FIELD_KEY_GRANTED = 'granted';

View File

@ -2,8 +2,8 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { parseDataAttributes } from '~/members/utils'; import { parseDataAttributes } from '~/members/utils';
import { MEMBER_TYPES } from 'ee_else_ce/members/constants';
import MembersTabs from './components/members_tabs.vue'; import MembersTabs from './components/members_tabs.vue';
import { MEMBER_TYPES } from './constants';
import membersStore from './store'; import membersStore from './store';
export const initMembersApp = (el, options) => { export const initMembersApp = (el, options) => {

View File

@ -81,7 +81,7 @@ export default {
</script> </script>
<template> <template>
<settings-block :collapsible="false"> <settings-block data-testid="container-expiration-policy-project-settings">
<template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template> <template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template>
<template #description> <template #description>
<span> <span>

View File

@ -30,6 +30,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
description: {
type: String,
required: false,
default: '',
},
}, },
}; };
</script> </script>
@ -46,5 +51,10 @@ export default {
{{ option.label }} {{ option.label }}
</option> </option>
</gl-form-select> </gl-form-select>
<template v-if="description" #description>
<span data-testid="description" class="gl-text-gray-400">
{{ description }}
</span>
</template>
</gl-form-group> </gl-form-group>
</template> </template>

View File

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

View File

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

View File

@ -1,15 +1,19 @@
<script> <script>
import ContainerExpirationPolicy from './container_expiration_policy.vue'; import ContainerExpirationPolicy from './container_expiration_policy.vue';
import PackagesCleanupPolicy from './packages_cleanup_policy.vue';
export default { export default {
components: { components: {
ContainerExpirationPolicy, ContainerExpirationPolicy,
PackagesCleanupPolicy,
}, },
inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'],
}; };
</script> </script>
<template> <template>
<section data-testid="registry-settings-app"> <div>
<container-expiration-policy /> <packages-cleanup-policy v-if="showPackageRegistrySettings" />
</section> <container-expiration-policy v-if="showContainerRegistrySettings" />
</div>
</template> </template>

View File

@ -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', '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 = [ export const KEEP_N_OPTIONS = [
{ key: 'ONE_TAG', variable: 1, default: false }, { key: 'ONE_TAG', variable: 1, default: false },
{ key: 'FIVE_TAGS', variable: 5, default: false }, { key: 'FIVE_TAGS', variable: 5, default: false },

View File

@ -0,0 +1,4 @@
fragment PackagesCleanupPolicyFields on PackagesCleanupPolicy {
keepNDuplicatedPackageFiles
nextRunAt
}

View File

@ -0,0 +1,10 @@
#import "../fragments/packages_cleanup_policy.fragment.graphql"
mutation updatePackagesCleanupPolicy($input: UpdatePackagesCleanupPolicyInput!) {
updatePackagesCleanupPolicy(input: $input) {
packagesCleanupPolicy {
...PackagesCleanupPolicyFields
}
errors
}
}

View File

@ -0,0 +1,10 @@
#import "../fragments/packages_cleanup_policy.fragment.graphql"
query getProjectPackagesCleanupPolicy($projectPath: ID!) {
project(fullPath: $projectPath) {
id
packagesCleanupPolicy {
...PackagesCleanupPolicyFields
}
}
}

View File

@ -20,6 +20,8 @@ export default () => {
adminSettingsPath, adminSettingsPath,
tagsRegexHelpPagePath, tagsRegexHelpPagePath,
helpPagePath, helpPagePath,
showContainerRegistrySettings,
showPackageRegistrySettings,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
el, el,
@ -34,6 +36,8 @@ export default () => {
adminSettingsPath, adminSettingsPath,
tagsRegexHelpPagePath, tagsRegexHelpPagePath,
helpPagePath, helpPagePath,
showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
showPackageRegistrySettings: parseBoolean(showPackageRegistrySettings),
}, },
render(createElement) { render(createElement) {
return createElement('registry-settings-app', {}); return createElement('registry-settings-app', {});

View File

@ -1,5 +1,11 @@
import { n__ } from '~/locale'; 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) => { export const findDefaultOption = (options) => {
const item = options.find((o) => o.default); const item = options.find((o) => o.default);
@ -25,5 +31,6 @@ export const formOptionsGenerator = () => {
olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
cadence: CADENCE_OPTIONS, cadence: CADENCE_OPTIONS,
keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
[KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME]: KEEP_N_DUPLICATED_PACKAGE_FILES_OPTIONS,
}; };
}; };

View File

@ -5,12 +5,11 @@ import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { initMembersApp } from '~/members'; 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'; import { groupLinkRequestFormatter } from '~/members/utils';
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions']; const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
const APP_OPTIONS = {
initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: { [MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']), tableFields: SHARED_FIELDS.concat(['source', 'granted', 'userCreatedAt', 'lastActivityOn']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
@ -61,7 +60,10 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
tableFields: SHARED_FIELDS.concat('requested'), tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: groupMemberRequestFormatter, requestFormatter: groupMemberRequestFormatter,
}, },
}); ...EE_APP_OPTIONS,
};
initMembersApp(document.querySelector('.js-group-members-list-app'), APP_OPTIONS);
initInviteMembersModal(); initInviteMembersModal();
initInviteGroupsModal(); initInviteGroupsModal();

View File

@ -61,6 +61,10 @@ export default {
GlFormCheckbox, GlFormCheckbox,
GlToggle, GlToggle,
ConfirmDanger, ConfirmDanger,
otherProjectSettings: () =>
import(
'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue'
),
}, },
mixins: [settingsMixin, glFeatureFlagsMixin()], mixins: [settingsMixin, glFeatureFlagsMixin()],
@ -905,6 +909,7 @@ export default {
<template #help>{{ $options.i18n.pucWarningHelpText }}</template> <template #help>{{ $options.i18n.pucWarningHelpText }}</template>
</gl-form-checkbox> </gl-form-checkbox>
</project-setting-row> </project-setting-row>
<other-project-settings />
<confirm-danger <confirm-danger
v-if="isVisibilityReduced" v-if="isVisibilityReduced"
button-variant="confirm" button-variant="confirm"

View File

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

View File

@ -9,7 +9,7 @@ module Groups::GroupMembersHelper
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end 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 } }), 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), group: group_group_links_list_data(group, include_relations, search),

View File

@ -53,4 +53,14 @@ module PackagesHelper
project.container_expiration_policy.nil? && project.container_expiration_policy.nil? &&
project.container_repositories.exists? project.container_repositories.exists?
end 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 end

View File

@ -143,17 +143,34 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass
end end
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 def users
User.where(id: user_ids) User.where(id: user_ids)
end end
def users_with_last_activity def users_with_last_activity
# where in (x, y, [...z]) is a set and does not maintain any order, we need to # where in (x, y, [...z]) is a set and does not maintain any order, we need
# make sure to establish a stable order for both, the pairs returned from # to make sure to establish a stable order for both, the pairs returned from
# redis and the ActiveRecord query. Using IDs in ascending order. # redis and the ActiveRecord query. Using IDs in ascending order.
user_ids, last_activities = user_ids_with_last_activity user_ids, last_activities = user_ids_with_last_activity
.sort_by(&:first) .sort_by(&:first)
.transpose .transpose
return [] if user_ids.blank?
users = User.where(id: user_ids).order(id: :asc) users = User.where(id: user_ids).order(id: :asc)
users.zip(last_activities) users.zip(last_activities)
end end
@ -162,6 +179,10 @@ class AwarenessSession # rubocop:disable Gitlab/NamespacedClass
attr_reader :id 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 # converts session id from hex to integer representation
def id_i def id_i
Integer(id, 16) if id.present? Integer(id, 16) if id.present?

View File

@ -225,6 +225,10 @@ class ProjectPolicy < BasePolicy
Gitlab.config.registry.enabled Gitlab.config.registry.enabled
end end
condition :packages_enabled do
Gitlab.config.packages.enabled
end
# `:read_project` may be prevented in EE, but `:read_project_for_iids` should # `:read_project` may be prevented in EE, but `:read_project_for_iids` should
# not. # not.
rule { guest | admin }.enable :read_project_for_iids rule { guest | admin }.enable :read_project_for_iids
@ -795,6 +799,10 @@ class ProjectPolicy < BasePolicy
enable :view_package_registry_project_settings enable :view_package_registry_project_settings
end end
rule { packages_enabled & can?(:admin_package) }.policy do
enable :view_package_registry_project_settings
end
private private
def user_is_user? def user_is_user?

View File

@ -26,6 +26,7 @@
members: @members, members: @members,
invited: @invited_members, invited: @invited_members,
access_requests: @requesters, access_requests: @requesters,
banned: @banned || [],
include_relations: @include_relations, include_relations: @include_relations,
search: params[:search_groups]).to_json } } search: params[:search_groups]).to_json } }
= gl_loading_icon(css_class: 'gl-my-5', size: 'md') = gl_loading_icon(css_class: 'gl-my-5', size: 'md')

View File

@ -8,6 +8,8 @@
keep_n_options: keep_n_options.to_json, keep_n_options: keep_n_options.to_json,
older_than_options: older_than_options.to_json, older_than_options: older_than_options.to_json,
is_admin: current_user&.admin.to_s, 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'), admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
enable_historic_entries: container_expiration_policies_historic_entry_enabled?.to_s, 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'), help_page_path: help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy'),

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
f0bba8e67c97d6dea461d8626a07820c52e20ab6578ad40e8873ad0031a2ce62

View File

@ -0,0 +1 @@
db2c19f15a03a6222627875d8bd27368de43fb6485961f866de61b3017796e28

View File

@ -0,0 +1 @@
42387b8524845aeb76d8b6584ffa480819f682538ca9578492eed53baa49bc09

View File

@ -53,6 +53,7 @@ DECLARE
state smallint; state smallint;
report_type smallint; report_type smallint;
resolved_on_default_branch boolean; resolved_on_default_branch boolean;
present_on_default_branch boolean;
BEGIN BEGIN
IF (NEW.vulnerability_id IS NULL AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE')) THEN IF (NEW.vulnerability_id IS NULL AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE')) THEN
RETURN NULL; RETURN NULL;
@ -63,14 +64,18 @@ BEGIN
END IF; END IF;
SELECT 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 INTO
severity, state, report_type, resolved_on_default_branch severity, state, report_type, resolved_on_default_branch, present_on_default_branch
FROM FROM
vulnerabilities vulnerabilities
WHERE WHERE
vulnerabilities.id = NEW.vulnerability_id; 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) 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)) 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; ON CONFLICT(vulnerability_id) DO NOTHING;
@ -89,6 +94,33 @@ RETURN NULL;
END 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[] CREATE FUNCTION next_traversal_ids_sibling(traversal_ids integer[]) RETURNS integer[]
LANGUAGE plpgsql IMMUTABLE STRICT LANGUAGE plpgsql IMMUTABLE STRICT
AS $$ 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_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_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(); 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_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(); 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();

View File

@ -7,18 +7,32 @@ type: reference
# Password storage **(FREE)** # 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 GitLab stores user passwords in a hashed format to prevent passwords from being
stored as plain text. stored as plain text.
GitLab uses the [Devise](https://github.com/heartcombo/devise) authentication GitLab uses the [Devise](https://github.com/heartcombo/devise) authentication
library to hash user passwords. Created password hashes have these attributes: library to hash user passwords. Created password hashes have these attributes:
- **Hashing**: The [`bcrypt`](https://en.wikipedia.org/wiki/Bcrypt) hashing - **Hashing**:
function is used to generate the hash of the provided password. This is a - **BCrypt**: By default, the [`bcrypt`](https://en.wikipedia.org/wiki/Bcrypt) hashing
strong, industry-standard cryptographic hashing function. 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) - **Stretching**: Password hashes are [stretched](https://en.wikipedia.org/wiki/Key_stretching)
to harden against brute-force attacks. By default, GitLab uses a 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)) - **Salting**: A [cryptographic salt](https://en.wikipedia.org/wiki/Salt_(cryptography))
is added to each password to harden against pre-computed hash and dictionary is added to each password to harden against pre-computed hash and dictionary
attacks. To increase security, each salt is randomly generated for each attacks. To increase security, each salt is randomly generated for each

View File

@ -243,29 +243,13 @@ See [Multiple Kubernetes clusters for Auto DevOps](multiple_clusters_auto_devops
## Customizing the Kubernetes namespace ## 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 You should now use the `KUBE_NAMESPACE` environment variable and
`.gitlab-ci.yml` by specifying [limit the environments it is available for](../../ci/environments/index.md#scope-environments-with-specs).
[`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.
## Using components of Auto DevOps ## Using components of Auto DevOps

View File

@ -303,7 +303,9 @@ support `-fips`.
Starting with GitLab 14.10, `-fips` is automatically added to `CS_ANALYZER_IMAGE` when FIPS mode is Starting with GitLab 14.10, `-fips` is automatically added to `CS_ANALYZER_IMAGE` when FIPS mode is
enabled in the GitLab instance. 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 ### Enable Container Scanning through an automatic merge request

View File

@ -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 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. `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 ```yaml
stages: stages:
@ -121,15 +121,18 @@ upload:
TERRAFORM_MODULE_DIR: ${CI_PROJECT_DIR} # The path to your Terraform module 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_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_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: script:
- tar -cvzf ${TERRAFORM_MODULE_NAME}-${TERRAFORM_MODULE_SYSTEM}-${TERRAFORM_MODULE_VERSION}.tgz -C ${TERRAFORM_MODULE_DIR} --exclude=./.git . - TERRAFORM_MODULE_NAME=$(echo "${TERRAFORM_MODULE_NAME}" | tr " _" -) # module-name must not have spaces or underscores, so translate them to hyphens
- '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' - 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: rules:
- if: $CI_COMMIT_TAG - 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. 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 ## Example projects

View File

@ -5802,6 +5802,9 @@ msgstr ""
msgid "BambooService|The user with API access to the Bamboo server." msgid "BambooService|The user with API access to the Bamboo server."
msgstr "" msgstr ""
msgid "Banned"
msgstr ""
msgid "Banner message" msgid "Banner message"
msgstr "" msgstr ""
@ -27707,6 +27710,9 @@ msgstr ""
msgid "PackageRegistry|Error publishing" msgid "PackageRegistry|Error publishing"
msgstr "" msgstr ""
msgid "PackageRegistry|Examples of assets include .pom & .jar files"
msgstr ""
msgid "PackageRegistry|Failed to load the package data" msgid "PackageRegistry|Failed to load the package data"
msgstr "" msgstr ""
@ -27764,6 +27770,9 @@ msgstr ""
msgid "PackageRegistry|License information located at %{link}" msgid "PackageRegistry|License information located at %{link}"
msgstr "" msgstr ""
msgid "PackageRegistry|Manage storage used by package assets"
msgstr ""
msgid "PackageRegistry|Manually Published" msgid "PackageRegistry|Manually Published"
msgstr "" msgstr ""
@ -27782,6 +27791,9 @@ msgstr ""
msgid "PackageRegistry|NuGet Command" msgid "PackageRegistry|NuGet Command"
msgstr "" msgstr ""
msgid "PackageRegistry|Number of duplicate assets to keep"
msgstr ""
msgid "PackageRegistry|Package Registry" msgid "PackageRegistry|Package Registry"
msgstr "" msgstr ""
@ -27907,6 +27919,9 @@ msgstr ""
msgid "PackageRegistry|Unable to load package" msgid "PackageRegistry|Unable to load package"
msgstr "" 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?" msgid "PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?"
msgstr "" msgstr ""

View File

@ -37,6 +37,8 @@ module QA
end end
before do before do
Runtime::Feature.disable(:simulate_pipeline)
Flow::Login.sign_in Flow::Login.sign_in
project.visit! project.visit!
Page::Project::Menu.perform(&:go_to_pipeline_editor) Page::Project::Menu.perform(&:go_to_pipeline_editor)

View File

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

View File

@ -49,7 +49,7 @@ RSpec.describe 'Project navbar' do
stub_config(pages: { enabled: true }) stub_config(pages: { enabled: true })
insert_after_sub_nav_item( insert_after_sub_nav_item(
_('CI/CD'), _('Packages & Registries'),
within: _('Settings'), within: _('Settings'),
new_sub_nav_item_name: _('Pages') new_sub_nav_item_name: _('Pages')
) )
@ -60,18 +60,22 @@ RSpec.describe 'Project navbar' do
it_behaves_like 'verified navigation bar' it_behaves_like 'verified navigation bar'
end 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 context 'when container registry is available' do
before do before do
stub_config(registry: { enabled: true }) stub_config(registry: { enabled: true })
insert_container_nav insert_container_nav
insert_after_sub_nav_item(
_('CI/CD'),
within: _('Settings'),
new_sub_nav_item_name: _('Packages & Registries')
)
visit project_path(project) visit project_path(project)
end end

View File

@ -2,7 +2,7 @@
require 'spec_helper' 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(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, namespace: user.namespace) } 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 it 'shows available section' do
subject 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' expect(settings_block).to have_text 'Clean up image tags'
end end
it 'saves cleanup policy submit the form' do it 'saves cleanup policy submit the form' do
subject 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('Every day', from: 'Run cleanup')
select('50 tags per image name', from: 'Keep the most recent:') select('50 tags per image name', from: 'Keep the most recent:')
fill_in('Keep tags matching:', with: 'stable') 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 it 'does not save cleanup policy submit form with invalid regex' do
subject 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') fill_in('Remove tags matching:', with: '*-production')
submit_button = find('[data-testid="save-button"') 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 it 'displays the related section' do
subject 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.') expect(find('[data-testid="enable-toggle"]')).to have_content('Disabled - Tags will not be automatically deleted.')
end end
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 it 'does not display the related section' do
subject 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') expect(find('.gl-alert-title')).to have_content('Cleanup policy for tags is disabled')
end end
end end
@ -100,7 +104,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
it 'does not exists' do it 'does not exists' do
subject 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 end
@ -110,7 +114,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
it 'does not exists' do it 'does not exists' do
subject 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 end
end end

View File

@ -4,6 +4,7 @@ exports[`Container Expiration Policy Settings Form Cadence matches snapshot 1`]
<expiration-dropdown-stub <expiration-dropdown-stub
class="gl-mr-7 gl-mb-0!" class="gl-mr-7 gl-mb-0!"
data-testid="cadence-dropdown" data-testid="cadence-dropdown"
description=""
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Run cleanup:" label="Run cleanup:"
name="cadence" 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`] = ` exports[`Container Expiration Policy Settings Form Keep N matches snapshot 1`] = `
<expiration-dropdown-stub <expiration-dropdown-stub
data-testid="keep-n-dropdown" data-testid="keep-n-dropdown"
description=""
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Keep the most recent:" label="Keep the most recent:"
name="keep-n" 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`] = ` exports[`Container Expiration Policy Settings Form OlderThan matches snapshot 1`] = `
<expiration-dropdown-stub <expiration-dropdown-stub
data-testid="older-than-dropdown" data-testid="older-than-dropdown"
description=""
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Remove tags older than:" label="Remove tags older than:"
name="older-than" name="older-than"

View File

@ -43,11 +43,6 @@ describe('Container expiration policy project settings', () => {
GlSprintf, GlSprintf,
SettingsBlock, SettingsBlock,
}, },
mocks: {
$toast: {
show: jest.fn(),
},
},
provide, provide,
...config, ...config,
}); });
@ -98,7 +93,7 @@ describe('Container expiration policy project settings', () => {
await waitForPromises(); await waitForPromises();
expect(findFormComponent().exists()).toBe(true); expect(findFormComponent().exists()).toBe(true);
expect(findSettingsBlock().props('collapsible')).toBe(false); expect(findSettingsBlock().exists()).toBe(true);
}); });
describe('the form is disabled', () => { describe('the form is disabled', () => {

View File

@ -16,6 +16,7 @@ describe('ExpirationDropdown', () => {
const findFormSelect = () => wrapper.find(GlFormSelect); const findFormSelect = () => wrapper.find(GlFormSelect);
const findFormGroup = () => wrapper.find(GlFormGroup); const findFormGroup = () => wrapper.find(GlFormGroup);
const findDescription = () => wrapper.find('[data-testid="description"]');
const findOptions = () => wrapper.findAll('[data-testid="option"]'); const findOptions = () => wrapper.findAll('[data-testid="option"]');
const mountComponent = (props) => { const mountComponent = (props) => {
@ -47,6 +48,14 @@ describe('ExpirationDropdown', () => {
expect(findOptions()).toHaveLength(defaultProps.formOptions.length); expect(findOptions()).toHaveLength(defaultProps.formOptions.length);
}); });
it('renders the description if passed', () => {
mountComponent({
description: 'test description',
});
expect(findDescription().html()).toContain('test description');
});
}); });
describe('model', () => { describe('model', () => {

View File

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

View File

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

View File

@ -1,19 +1,41 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; 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 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', () => { describe('Registry Settings app', () => {
let wrapper; let wrapper;
const findContainerExpirationPolicy = () => wrapper.find(ContainerExpirationPolicy); const findContainerExpirationPolicy = () => wrapper.find(ContainerExpirationPolicy);
const findPackagesCleanupPolicy = () => wrapper.find(PackagesCleanupPolicy);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
it('renders container expiration policy component', () => { const mountComponent = (provide) => {
wrapper = shallowMount(component); 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);
},
);
}); });

View File

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

View File

@ -44,6 +44,7 @@ RSpec.describe Groups::GroupMembersHelper do
members: present_members(members_collection), members: present_members(members_collection),
invited: present_members(invited), invited: present_members(invited),
access_requests: present_members(access_requests), access_requests: present_members(access_requests),
banned: [],
include_relations: [:inherited, :direct], include_relations: [:inherited, :direct],
search: nil search: nil
) )
@ -117,6 +118,7 @@ RSpec.describe Groups::GroupMembersHelper do
members: present_members(members_collection), members: present_members(members_collection),
invited: present_members(invited), invited: present_members(invited),
access_requests: present_members(access_requests), access_requests: present_members(access_requests),
banned: [],
include_relations: include_relations, include_relations: include_relations,
search: nil search: nil
) )

View File

@ -31,16 +31,14 @@ RSpec.describe Gitlab::Ci::RunnerUpgradeCheck do
end end
context 'with available_runner_releases configured' do context 'with available_runner_releases configured' do
let(:runner_releases_double) { instance_double(Gitlab::Ci::RunnerReleases) }
before do before do
allow(Gitlab::Ci::RunnerReleases).to receive(:instance).and_return(runner_releases_double) url = ::Gitlab::CurrentSettings.current_application_settings.public_runner_releases_url
allow(runner_releases_double).to receive(:releases)
.and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) }) WebMock.stub_request(:get, url).to_return(
allow(runner_releases_double).to receive(:releases_by_minor) body: available_runner_releases.map { |v| { name: v } }.to_json,
.and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) } status: 200,
.group_by(&:without_patch) headers: { 'Content-Type' => 'application/json' }
.transform_values(&:max)) )
end end
context 'with no available runner releases' do context 'with no available runner releases' do

View File

@ -135,18 +135,20 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do
describe 'Packages & Registries' do describe 'Packages & Registries' do
let(:item_id) { :packages_and_registries } let(:item_id) { :packages_and_registries }
let(:packages_enabled) { false }
before do before do
stub_container_registry_config(enabled: container_enabled) stub_container_registry_config(enabled: container_enabled)
stub_config(packages: { enabled: packages_enabled })
end end
describe 'when config registry setting is disabled' do describe 'when container registry setting is disabled' do
let(:container_enabled) { false } let(:container_enabled) { false }
specify { is_expected.to be_nil } specify { is_expected.to be_nil }
end end
describe 'when config registry setting is enabled' do describe 'when container registry setting is enabled' do
let(:container_enabled) { true } let(:container_enabled) { true }
specify { is_expected.not_to be_nil } specify { is_expected.not_to be_nil }
@ -157,6 +159,19 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do
specify { is_expected.to be_nil } specify { is_expected.to be_nil }
end end
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 end
describe 'Usage Quotas' do describe 'Usage Quotas' do

View File

@ -2,14 +2,24 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe AwarenessSession do RSpec.describe AwarenessSession, :clean_gitlab_redis_shared_state do
subject { AwarenessSession.for(session_id) } subject { AwarenessSession.for(session_id) }
let!(:user) { create(:user) } let!(:user) { create(:user) }
let(:session_id) { 1 } let(:session_id) { 1 }
after do describe "when initiating a session" do
redis_shared_state_cleanup! 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 end
describe "when a user joins a session" do describe "when a user joins a session" do
@ -103,6 +113,26 @@ RSpec.describe AwarenessSession do
expect(ttl_user).to be > 0 expect(ttl_user).to be > 0
end end
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 end
describe "when a user leaves a session" do describe "when a user leaves a session" do

View File

@ -2,15 +2,11 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Awareness do RSpec.describe Awareness, :clean_gitlab_redis_shared_state do
subject { create(:user) } subject { create(:user) }
let(:session) { AwarenessSession.for(1) } let(:session) { AwarenessSession.for(1) }
after do
redis_shared_state_cleanup!
end
describe "when joining a session" do describe "when joining a session" do
it "increases the number of sessions" do it "increases the number of sessions" do
expect { subject.join(session) } expect { subject.join(session) }

View File

@ -1480,45 +1480,144 @@ RSpec.describe ProjectPolicy do
end end
describe 'view_package_registry_project_settings' do describe 'view_package_registry_project_settings' do
context 'with registry enabled' do context 'with packages disabled and' do
before do before do
stub_config(registry: { enabled: true }) stub_config(packages: { enabled: false })
end end
context 'with an admin user' do context 'with registry enabled' do
let(:current_user) { admin } before do
stub_config(registry: { enabled: true })
context 'when admin mode enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:view_package_registry_project_settings) }
end end
context 'when admin mode disabled' do context 'with an admin user' do
it { is_expected.to be_disallowed(:view_package_registry_project_settings) } 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
end end
%i[owner maintainer].each do |role| context 'with registry disabled' do
context "with #{role}" do before do
let(:current_user) { public_send(role) } stub_config(registry: { enabled: false })
it { is_expected.to be_allowed(:view_package_registry_project_settings) }
end end
end
%i[developer reporter guest non_member anonymous].each do |role| context 'with admin user' do
context "with #{role}" do let(:current_user) { admin }
let(:current_user) { public_send(role) }
it { is_expected.to be_disallowed(:view_package_registry_project_settings) } 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 end
end end
context 'with registry disabled' do context 'with registry disabled and' do
before do before do
stub_config(registry: { enabled: false }) stub_config(registry: { enabled: false })
end end
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 & packages both disabled' do
before do
stub_config(registry: { enabled: false })
stub_config(packages: { enabled: false })
end
context 'with admin user' do context 'with admin user' do
let(:current_user) { admin } let(:current_user) { admin }

View File

@ -10,78 +10,128 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute'
create(:ci_runner_version, version: '14.0.1', status: :not_available) create(:ci_runner_version, version: '14.0.1', status: :not_available)
end end
before do context 'with RunnerUpgradeCheck recommending 14.0.2' 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') }
before do before do
stub_const('Ci::Runners::ReconcileExistingRunnerVersionsService::VERSION_BATCH_SIZE', 1)
allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) allow(::Gitlab::Ci::RunnerUpgradeCheck.instance)
.to receive(:check_runner_upgrade_status) .to receive(:check_runner_upgrade_status)
.with('14.0.2') .and_return({ recommended: ::Gitlab::VersionInfo.new(14, 0, 2) })
.and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) })
.once
end end
it 'creates and updates expected ci_runner_versions entries', :aggregate_failures do context 'with runner with new version' do
expect(Ci::RunnerVersion).to receive(:insert_all) let!(:runner_14_0_2) { create(:ci_runner, version: '14.0.2') }
.ordered let!(:runner_version_14_0_0) { create(:ci_runner_version, version: '14.0.0', status: :not_available) }
.with([{ version: '14.0.2' }], anything) let!(:runner_14_0_0) { create(:ci_runner, version: '14.0.0') }
.once
.and_call_original
result = nil before do
expect { result = execute } allow(::Gitlab::Ci::RunnerUpgradeCheck.instance)
.to change { runner_version_14_0_0.reload.status }.from('not_available').to('recommended') .to receive(:check_runner_upgrade_status)
.and change { runner_version_14_0_1.reload.status }.from('not_available').to('recommended') .with('14.0.2')
.and change { ::Ci::RunnerVersion.find_by(version: '14.0.2')&.status }.from(nil).to('not_available') .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) })
.once
end
expect(result).to eq({ it 'creates and updates expected ci_runner_versions entries', :aggregate_failures do
status: :success, expect(Ci::RunnerVersion).to receive(:insert_all)
total_inserted: 1, # 14.0.2 is inserted .ordered
total_updated: 3, # 14.0.0, 14.0.1 are updated, and newly inserted 14.0.2's status is calculated .with([{ version: '14.0.2' }], anything)
total_deleted: 0 .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
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
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
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
end end
context 'with orphan ci_runner_version' do context 'integration testing with Gitlab::Ci::RunnerUpgradeCheck' do
let!(:runner_version_14_0_2) { create(:ci_runner_version, version: '14.0.2', status: :not_available) } let(:available_runner_releases) do
%w[14.0.0 14.0.1]
before do
allow(::Gitlab::Ci::RunnerUpgradeCheck.instance)
.to receive(:check_runner_upgrade_status)
.and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) })
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
})
end
end
context 'with no runner version changes' do
before do before do
allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) url = ::Gitlab::CurrentSettings.current_application_settings.public_runner_releases_url
.to receive(:check_runner_upgrade_status)
.and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 1) }) 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 end
it 'does not modify ci_runner_versions entries', :aggregate_failures do it 'does not modify ci_runner_versions entries', :aggregate_failures do
@ -96,24 +146,4 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute'
}) })
end end
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) })
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 end

View File

@ -110,6 +110,7 @@ RSpec.shared_context 'project navbar structure' do
_('Access Tokens'), _('Access Tokens'),
_('Repository'), _('Repository'),
_('CI/CD'), _('CI/CD'),
_('Packages & Registries'),
_('Monitor'), _('Monitor'),
s_('UsageQuota|Usage Quotas') s_('UsageQuota|Usage Quotas')
] ]

View File

@ -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') expect(rendered).not_to have_content('You can invite a new member')
end end
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 end

View File

@ -909,8 +909,11 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end end
describe 'Packages & Registries' do describe 'Packages & Registries' do
let(:packages_enabled) { false }
before do before do
stub_container_registry_config(enabled: registry_enabled) stub_container_registry_config(enabled: registry_enabled)
stub_config(packages: { enabled: packages_enabled })
end end
context 'when registry is enabled' do 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)) expect(rendered).not_to have_link('Packages & Registries', href: project_settings_packages_and_registries_path(project))
end end
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 end
describe 'Usage Quotas' do describe 'Usage Quotas' do