diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-16 03:15:50 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-16 03:15:50 +0300 |
commit | e04431d29efaf17dda9dfbfbd0c5001693b25ee4 (patch) | |
tree | f114abad1f4882ef6c9c702e8de3a84334809031 | |
parent | 1c898dc5c10bbedf94386d917259153d73608495 (diff) |
Add latest changes from gitlab-org/gitlab@master
96 files changed, 1844 insertions, 617 deletions
diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue new file mode 100644 index 00000000000..3954e541fe0 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/token.vue @@ -0,0 +1,55 @@ +<script> +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +export default { + components: { InputCopyToggleVisibility }, + props: { + token: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + inputLabel: { + type: String, + required: true, + }, + copyButtonTitle: { + type: String, + required: true, + }, + }, + computed: { + formInputGroupProps() { + return { id: this.inputId }; + }, + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-lg-12"> + <hr /> + </div> + <div class="col-lg-4"> + <h4 class="gl-mt-0"><slot name="title"></slot></h4> + <slot name="description"></slot> + </div> + <div class="col-lg-8"> + <input-copy-toggle-visibility + :label="inputLabel" + :label-for="inputId" + :form-input-group-props="formInputGroupProps" + :value="token" + :copy-button-title="copyButtonTitle" + > + <template #description> + <slot name="input-description"></slot> + </template> + </input-copy-toggle-visibility> + </div> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue new file mode 100644 index 00000000000..755991f64e0 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -0,0 +1,111 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { pickBy } from 'lodash'; + +import { s__ } from '~/locale'; + +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '../constants'; +import Token from './token.vue'; + +export default { + i18n: { + canNotAccessOtherData: s__('AccessTokens|It cannot be used to access any other data.'), + [FEED_TOKEN]: { + label: s__('AccessTokens|Feed token'), + copyButtonTitle: s__('AccessTokens|Copy feed token'), + description: s__( + 'AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__( + 'AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.', + ), + }, + [INCOMING_EMAIL_TOKEN]: { + label: s__('AccessTokens|Incoming email token'), + copyButtonTitle: s__('AccessTokens|Copy incoming email token'), + description: s__( + 'AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__( + 'AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.', + ), + }, + [STATIC_OBJECT_TOKEN]: { + label: s__('AccessTokens|Static object token'), + copyButtonTitle: s__('AccessTokens|Copy static object token'), + description: s__( + 'AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__('AccessTokens|Are you sure?'), + }, + }, + htmlAttributes: { + [FEED_TOKEN]: { + inputId: 'feed_token', + containerTestId: 'feed-token-container', + }, + [INCOMING_EMAIL_TOKEN]: { + inputId: 'incoming_email_token', + containerTestId: 'incoming-email-token-container', + }, + [STATIC_OBJECT_TOKEN]: { + inputId: 'static_object_token', + containerTestId: 'static-object-token-container', + }, + }, + components: { Token, GlSprintf, GlLink }, + inject: ['tokenTypes'], + computed: { + enabledTokenTypes() { + return pickBy(this.tokenTypes, (tokenData, tokenType) => { + return ( + tokenData?.enabled && + this.$options.i18n[tokenType] && + this.$options.htmlAttributes[tokenType] + ); + }); + }, + }, +}; +</script> + +<template> + <div> + <token + v-for="(tokenData, tokenType) in enabledTokenTypes" + :key="tokenType" + :token="tokenData.token" + :input-id="$options.htmlAttributes[tokenType].inputId" + :input-label="$options.i18n[tokenType].label" + :copy-button-title="$options.i18n[tokenType].copyButtonTitle" + :data-testid="$options.htmlAttributes[tokenType].containerTestId" + > + <template #title>{{ $options.i18n[tokenType].label }}</template> + <template #description> + <p>{{ $options.i18n[tokenType].description }}</p> + <p>{{ $options.i18n.canNotAccessOtherData }}</p> + </template> + <template #input-description> + <gl-sprintf :message="$options.i18n[tokenType].inputDescription"> + <template #link="{ content }"> + <gl-link + :href="tokenData.resetPath" + :data-confirm="$options.i18n[tokenType].resetConfirmMessage" + data-method="put" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </template> + </token> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/constants.js b/app/assets/javascripts/access_tokens/constants.js new file mode 100644 index 00000000000..6188c6d1bb5 --- /dev/null +++ b/app/assets/javascripts/access_tokens/constants.js @@ -0,0 +1,4 @@ +// Token types +export const FEED_TOKEN = 'feedToken'; +export const INCOMING_EMAIL_TOKEN = 'incomingEmailToken'; +export const STATIC_OBJECT_TOKEN = 'staticObjectToken'; diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 7f5f0403de6..9a1e7d877f8 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,9 +1,13 @@ import Vue from 'vue'; + import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { parseRailsFormFields } from '~/lib/utils/forms'; import { __ } from '~/locale'; import ExpiresAtField from './components/expires_at_field.vue'; +import TokensApp from './components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants'; export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); @@ -81,3 +85,29 @@ export const initProjectsField = () => { return null; }; + +export const initTokensApp = () => { + const el = document.getElementById('js-tokens-app'); + + if (!el) return false; + + const tokensData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.tokensData), { + deep: true, + }); + + const tokenTypes = { + [FEED_TOKEN]: tokensData[FEED_TOKEN], + [INCOMING_EMAIL_TOKEN]: tokensData[INCOMING_EMAIL_TOKEN], + [STATIC_OBJECT_TOKEN]: tokensData[STATIC_OBJECT_TOKEN], + }; + + return new Vue({ + el, + provide: { + tokenTypes, + }, + render(createElement) { + return createElement(TokensApp); + }, + }); +}; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index ef445548e6e..8fe90b6bb15 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -33,7 +33,7 @@ class GlEmoji extends HTMLElement { this.dataset.unicodeVersion = unicodeVersion; emojiUnicode = emojiInfo.e; - this.innerHTML = emojiInfo.e; + this.textContent = emojiInfo.e; this.title = emojiInfo.d; } diff --git a/app/assets/javascripts/crm/components/new_contact_form.vue b/app/assets/javascripts/crm/components/contact_form.vue index 53e6bb1a123..81ae5c246be 100644 --- a/app/assets/javascripts/crm/components/new_contact_form.vue +++ b/app/assets/javascripts/crm/components/contact_form.vue @@ -4,7 +4,8 @@ import { produce } from 'immer'; import { __, s__ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_GROUP } from '~/graphql_shared/constants'; -import createContact from './queries/create_contact.mutation.graphql'; +import createContactMutation from './queries/create_contact.mutation.graphql'; +import updateContactMutation from './queries/update_contact.mutation.graphql'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; export default { @@ -21,6 +22,11 @@ export default { type: Boolean, required: true, }, + contact: { + type: Object, + required: false, + default: () => {}, + }, }, data() { return { @@ -35,66 +41,111 @@ export default { }, computed: { invalid() { - return this.firstName === '' || this.lastName === '' || this.email === ''; + const { firstName, lastName, email } = this; + + return firstName.trim() === '' || lastName.trim() === '' || email.trim() === ''; + }, + editMode() { + return Boolean(this.contact); + }, + title() { + return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle; + }, + buttonLabel() { + return this.editMode + ? this.$options.i18n.editButtonLabel + : this.$options.i18n.createButtonLabel; + }, + mutation() { + return this.editMode ? updateContactMutation : createContactMutation; + }, + variables() { + const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this; + + const variables = { + input: { + firstName, + lastName, + phone, + email, + description, + }, + }; + + if (editMode) { + variables.input.id = contact.id; + } else { + variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId); + } + + return variables; }, }, + mounted() { + if (this.editMode) { + const { contact } = this; + + this.firstName = contact.firstName || ''; + this.lastName = contact.lastName || ''; + this.phone = contact.phone || ''; + this.email = contact.email || ''; + this.description = contact.description || ''; + } + }, methods: { save() { + const { mutation, variables, updateCache, close } = this; + this.submitting = true; + return this.$apollo .mutate({ - mutation: createContact, - variables: { - input: { - groupId: convertToGraphQLId(TYPE_GROUP, this.groupId), - firstName: this.firstName, - lastName: this.lastName, - phone: this.phone, - email: this.email, - description: this.description, - }, - }, - update: this.updateCache, + mutation, + variables, + update: updateCache, }) .then(({ data }) => { - if (data.customerRelationsContactCreate.errors.length === 0) this.close(true); + if ( + data.customerRelationsContactCreate?.errors.length === 0 || + data.customerRelationsContactUpdate?.errors.length === 0 + ) { + close(true); + } this.submitting = false; }) .catch(() => { - this.errorMessages = [__('Something went wrong. Please try again.')]; + this.errorMessages = [this.$options.i18n.somethingWentWrong]; this.submitting = false; }); }, close(success) { this.$emit('close', success); }, - updateCache(store, { data: { customerRelationsContactCreate } }) { - if (customerRelationsContactCreate.errors.length > 0) { - this.errorMessages = customerRelationsContactCreate.errors; + updateCache(store, { data }) { + const mutationData = + data.customerRelationsContactCreate || data.customerRelationsContactUpdate; + + if (mutationData?.errors.length > 0) { + this.errorMessages = mutationData.errors; return; } - const variables = { - groupFullPath: this.groupFullPath, - }; - const sourceData = store.readQuery({ + const queryArgs = { query: getGroupContactsQuery, - variables, - }); + variables: { groupFullPath: this.groupFullPath }, + }; - const data = produce(sourceData, (draftState) => { + const sourceData = store.readQuery(queryArgs); + + queryArgs.data = produce(sourceData, (draftState) => { draftState.group.contacts.nodes = [ - ...sourceData.group.contacts.nodes, - customerRelationsContactCreate.contact, + ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id), + mutationData.contact, ]; }); - store.writeQuery({ - query: getGroupContactsQuery, - variables, - data, - }); + store.writeQuery(queryArgs); }, getDrawerHeaderHeight() { const wrapperEl = document.querySelector('.content-wrapper'); @@ -107,14 +158,17 @@ export default { }, }, i18n: { - buttonLabel: s__('Crm|Create new contact'), + createButtonLabel: s__('Crm|Create new contact'), + editButtonLabel: __('Save changes'), cancel: __('Cancel'), firstName: s__('Crm|First name'), lastName: s__('Crm|Last name'), email: s__('Crm|Email'), phone: s__('Crm|Phone number (optional)'), description: s__('Crm|Description (optional)'), - title: s__('Crm|New Contact'), + newTitle: s__('Crm|New contact'), + editTitle: s__('Crm|Edit contact'), + somethingWentWrong: __('Something went wrong. Please try again.'), }, }; </script> @@ -127,7 +181,7 @@ export default { @close="close(false)" > <template #title> - <h4>{{ $options.i18n.title }}</h4> + <h3>{{ title }}</h3> </template> <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> <ul class="gl-mb-0! gl-ml-5"> @@ -160,9 +214,9 @@ export default { variant="confirm" :disabled="invalid" :loading="submitting" - data-testid="create-new-contact-button" + data-testid="save-contact-button" type="submit" - >{{ $options.i18n.buttonLabel }}</gl-button + >{{ buttonLabel }}</gl-button > </span> </form> diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue index 7ff1d4fa1fd..178ce84c64d 100644 --- a/app/assets/javascripts/crm/components/contacts_root.vue +++ b/app/assets/javascripts/crm/components/contacts_root.vue @@ -2,9 +2,11 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants'; +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; -import NewContactForm from './new_contact_form.vue'; +import ContactForm from './contact_form.vue'; export default { components: { @@ -12,7 +14,7 @@ export default { GlButton, GlLoadingIcon, GlTable, - NewContactForm, + ContactForm, }, directives: { GlTooltip: GlTooltipDirective, @@ -47,11 +49,19 @@ export default { return this.$apollo.queries.contacts.loading; }, showNewForm() { - return this.$route.path.startsWith('/new'); + return this.$route.name === NEW_ROUTE_NAME; }, - canCreateNew() { + showEditForm() { + return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME; + }, + canAdmin() { return parseBoolean(this.canAdminCrmContact); }, + editingContact() { + return this.contacts.find( + (contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id), + ); + }, }, methods: { extractContacts(data) { @@ -61,16 +71,28 @@ export default { displayNewForm() { if (this.showNewForm) return; - this.$router.push({ path: '/new' }); + this.$router.push({ name: NEW_ROUTE_NAME }); }, hideNewForm(success) { if (success) this.$toast.show(s__('Crm|Contact has been added')); - this.$router.replace({ path: '/' }); + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, + hideEditForm(success) { + if (success) this.$toast.show(s__('Crm|Contact has been updated')); + + this.editingContactId = 0; + this.$router.replace({ name: INDEX_ROUTE_NAME }); }, getIssuesPath(path, value) { return `${path}?scope=all&state=opened&crm_contact_id=${value}`; }, + edit(value) { + if (this.showEditForm) return; + + this.editingContactId = value; + this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } }); + }, }, fields: [ { key: 'firstName', sortable: true }, @@ -87,7 +109,7 @@ export default { }, { key: 'id', - label: __('Issues'), + label: '', formatter: (id) => { return getIdFromGraphQLId(id); }, @@ -96,6 +118,7 @@ export default { i18n: { emptyText: s__('Crm|No contacts found'), issuesButtonLabel: __('View issues'), + editButtonLabel: __('Edit'), title: s__('Crm|Customer Relations Contacts'), newContact: s__('Crm|New contact'), errorText: __('Something went wrong. Please try again.'), @@ -116,7 +139,7 @@ export default { </h2> <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"> <gl-button - v-if="canCreateNew" + v-if="canAdmin" variant="confirm" data-testid="new-contact-button" @click="displayNewForm" @@ -125,7 +148,13 @@ export default { </gl-button> </div> </div> - <new-contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> + <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> + <contact-form + v-if="showEditForm" + :contact="editingContact" + :drawer-open="showEditForm" + @close="hideEditForm" + /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else @@ -138,11 +167,20 @@ export default { <template #cell(id)="data"> <gl-button v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + class="gl-mr-3" data-testid="issues-link" icon="issues" :aria-label="$options.i18n.issuesButtonLabel" :href="getIssuesPath(groupIssuesPath, data.value)" /> + <gl-button + v-if="canAdmin" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-contact-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + @click="edit(data.value)" + /> </template> </gl-table> </div> diff --git a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql new file mode 100644 index 00000000000..f55f6a10e0a --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_contact_fields.fragment.graphql" + +mutation updateContact($input: CustomerRelationsContactUpdateInput!) { + customerRelationsContactUpdate(input: $input) { + contact { + ...ContactFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js new file mode 100644 index 00000000000..3b085837aea --- /dev/null +++ b/app/assets/javascripts/crm/constants.js @@ -0,0 +1,3 @@ +export const INDEX_ROUTE_NAME = 'index'; +export const NEW_ROUTE_NAME = 'new'; +export const EDIT_ROUTE_NAME = 'edit'; diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js index 2362a593d45..f49ec64210f 100644 --- a/app/assets/javascripts/crm/contacts_bundle.js +++ b/app/assets/javascripts/crm/contacts_bundle.js @@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import CrmContactsRoot from './components/contacts_root.vue'; +import routes from './routes'; Vue.use(VueApollo); Vue.use(VueRouter); @@ -25,14 +26,7 @@ export default () => { const router = new VueRouter({ base: basePath, mode: 'history', - routes: [ - { - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Contacts List', - path: '/', - component: CrmContactsRoot, - }, - ], + routes, }); return new Vue({ diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/routes.js new file mode 100644 index 00000000000..586fd7b987d --- /dev/null +++ b/app/assets/javascripts/crm/routes.js @@ -0,0 +1,20 @@ +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants'; +import CrmContactsRoot from './components/contacts_root.vue'; + +export default [ + { + name: INDEX_ROUTE_NAME, + path: '/', + component: CrmContactsRoot, + }, + { + name: NEW_ROUTE_NAME, + path: '/new', + component: CrmContactsRoot, + }, + { + name: EDIT_ROUTE_NAME, + path: '/:id/edit', + component: CrmContactsRoot, + }, +]; diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index e9f2272e759..a6eb4256561 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -16,3 +16,6 @@ export const CATEGORY_ICON_MAP = { export const EMOJIS_PER_ROW = 9; export const EMOJI_ROW_HEIGHT = 34; export const CATEGORY_ROW_HEIGHT = 37; + +export const CACHE_VERSION_KEY = 'gl-emoji-map-version'; +export const CACHE_KEY = 'gl-emoji-map'; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index c0f2153ce5b..b507792cc91 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,9 +1,9 @@ import { escape, minBy } from 'lodash'; +import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; -import { sanitize } from '~/lib/dompurify'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; -import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; +import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; let emojiMap = null; let validEmojiNames = null; @@ -17,10 +17,15 @@ const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); async function loadEmoji() { if ( isLocalStorageAvailable && - window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && - window.localStorage.getItem('gl-emoji-map') + window.localStorage.getItem(CACHE_VERSION_KEY) === EMOJI_VERSION && + window.localStorage.getItem(CACHE_KEY) ) { - return JSON.parse(window.localStorage.getItem('gl-emoji-map')); + const emojis = JSON.parse(window.localStorage.getItem(CACHE_KEY)); + // Workaround because the pride flag is broken in EMOJI_VERSION = '1' + if (emojis.gay_pride_flag) { + emojis.gay_pride_flag.e = '🏳️🌈'; + } + return emojis; } // We load the JSON file direct from the server @@ -29,15 +34,19 @@ async function loadEmoji() { const { data } = await axios.get( `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, ); - window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); - window.localStorage.setItem('gl-emoji-map', JSON.stringify(data)); + window.localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); + window.localStorage.setItem(CACHE_KEY, JSON.stringify(data)); return data; } async function loadEmojiWithNames() { - return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { - acc[key] = { ...value, name: key, e: sanitize(value.e) }; + const emojiRegex = emojiRegexFactory(); + return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { + // Filter out entries which aren't emojis + if (value.e.match(emojiRegex)?.[0] === value.e) { + acc[key] = { ...value, name: key }; + } return acc; }, {}); } diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 378573548f8..3b36c3e6ac5 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,6 +1,8 @@ export const MINIMUM_SEARCH_LENGTH = 3; export const TYPE_CI_RUNNER = 'Ci::Runner'; +export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; +export const TYPE_DISCUSSION = 'Discussion'; export const TYPE_EPIC = 'Epic'; export const TYPE_GROUP = 'Group'; export const TYPE_ISSUE = 'Issue'; @@ -8,11 +10,10 @@ export const TYPE_ITERATION = 'Iteration'; export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; export const TYPE_MERGE_REQUEST = 'MergeRequest'; export const TYPE_MILESTONE = 'Milestone'; +export const TYPE_NOTE = 'Note'; +export const TYPE_PACKAGES_PACKAGE = 'Packages::Package'; export const TYPE_PROJECT = 'Project'; export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_USER = 'User'; export const TYPE_VULNERABILITY = 'Vulnerability'; -export const TYPE_NOTE = 'Note'; -export const TYPE_DISCUSSION = 'Discussion'; -export const TYPE_PACKAGES_PACKAGE = 'Packages::Package'; diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index fdbfc35456f..37e9b7e99d4 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -1,4 +1,5 @@ -import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens'; initExpiresAtField(); initProjectsField(); +initTokensApp(); diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 4228a93d310..14dcec33545 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -23,9 +23,9 @@ module AuthenticatesWithTwoFactor session[:otp_user_id] = user.id session[:user_password_hash] = Digest::SHA256.hexdigest(user.encrypted_password) - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) - if Feature.enabled?(:webauthn) + if Feature.enabled?(:webauthn, default_enabled: :yaml) setup_webauthn_authentication(user) else setup_u2f_authentication(user) diff --git a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb index 574fc6c0f37..05be04059fd 100644 --- a/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb +++ b/app/controllers/concerns/authenticates_with_two_factor_for_admin_mode.rb @@ -11,7 +11,7 @@ module AuthenticatesWithTwoFactorForAdminMode return handle_locked_user(user) unless user.can?(:log_in) session[:otp_user_id] = user.id - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) if user.two_factor_webauthn_enabled? setup_webauthn_authentication(user) diff --git a/app/controllers/groups/crm/contacts_controller.rb b/app/controllers/groups/crm/contacts_controller.rb index 97904fdd2fd..f00f4d1df25 100644 --- a/app/controllers/groups/crm/contacts_controller.rb +++ b/app/controllers/groups/crm/contacts_controller.rb @@ -9,6 +9,10 @@ class Groups::Crm::ContactsController < Groups::ApplicationController render action: "index" end + def edit + render action: "index" + end + private def authorize_read_crm_contact! diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index e607346b40e..77fae34e2d2 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -8,7 +8,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController helper_method :current_password_required? before_action do - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) end feature_category :authentication_and_authorization @@ -44,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController @qr_code = build_qr_code @account_string = account_string - if Feature.enabled?(:webauthn) + if Feature.enabled?(:webauthn, default_enabled: :yaml) setup_webauthn_registration else setup_u2f_registration @@ -69,7 +69,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController @error = { message: _('Invalid pin code.') } @qr_code = build_qr_code - if Feature.enabled?(:webauthn) + if Feature.enabled?(:webauthn, default_enabled: :yaml) setup_webauthn_registration else setup_u2f_registration diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 6330a6aa107..e6b80f90dca 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,7 +8,7 @@ class ProfilesController < Profiles::ApplicationController before_action :authorize_change_username!, only: :update_username skip_before_action :require_email, only: [:show, :update] before_action do - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) end feature_category :users diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0d8e44656a9..7e8e3ea8789 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -32,7 +32,7 @@ class SessionsController < Devise::SessionsController before_action :load_recaptcha before_action :set_invite_params, only: [:new] before_action do - push_frontend_feature_flag(:webauthn) + push_frontend_feature_flag(:webauthn, default_enabled: :yaml) end after_action :log_failed_login, if: :action_new_and_failed_login? @@ -305,9 +305,9 @@ class SessionsController < Devise::SessionsController def authentication_method if user_params[:otp_attempt] AuthenticationEvent::TWO_FACTOR - elsif user_params[:device_response] && Feature.enabled?(:webauthn) + elsif user_params[:device_response] && Feature.enabled?(:webauthn, default_enabled: :yaml) AuthenticationEvent::TWO_FACTOR_WEBAUTHN - elsif user_params[:device_response] && !Feature.enabled?(:webauthn) + elsif user_params[:device_response] && !Feature.enabled?(:webauthn, default_enabled: :yaml) AuthenticationEvent::TWO_FACTOR_U2F else AuthenticationEvent::STANDARD diff --git a/app/helpers/access_tokens_helper.rb b/app/helpers/access_tokens_helper.rb index 877ad6db576..1d38262159f 100644 --- a/app/helpers/access_tokens_helper.rb +++ b/app/helpers/access_tokens_helper.rb @@ -1,7 +1,30 @@ # frozen_string_literal: true module AccessTokensHelper + include AccountsHelper + include ApplicationHelper + def scope_description(prefix) prefix == :project_access_token ? [:doorkeeper, :project_access_token_scope_desc] : [:doorkeeper, :scope_desc] end + + def tokens_app_data + { + feed_token: { + enabled: !Gitlab::CurrentSettings.disable_feed_token, + token: current_user.feed_token, + reset_path: reset_feed_token_profile_path + }, + incoming_email_token: { + enabled: incoming_email_token_enabled?, + token: current_user.enabled_incoming_email_token, + reset_path: reset_incoming_email_token_profile_path + }, + static_object_token: { + enabled: static_objects_external_storage_enabled?, + token: current_user.enabled_static_object_token, + reset_path: reset_static_object_token_profile_path + } + }.to_json + end end diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb index ba7e4b39989..8b8eca54550 100644 --- a/app/models/members_preloader.rb +++ b/app/models/members_preloader.rb @@ -13,7 +13,7 @@ class MembersPreloader ActiveRecord::Associations::Preloader.new.preload(members, :created_by) ActiveRecord::Associations::Preloader.new.preload(members, user: :status) ActiveRecord::Associations::Preloader.new.preload(members, user: :u2f_registrations) - ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn) + ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn, default_enabled: :yaml) end end diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb index f5c44171c42..91fa2b03c95 100644 --- a/app/models/namespaces/traversal/linear_scopes.rb +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -105,27 +105,32 @@ module Namespaces :traversal_ids, 'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids' ) - cte = Gitlab::SQL::CTE.new(:base_cte, base) + base_cte = Gitlab::SQL::CTE.new(:base_cte, base) namespaces = Arel::Table.new(:namespaces) - records = unscoped - .with(cte.to_arel) - .from([cte.table, namespaces]) # Bound the search space to ourselves (optional) and descendants. # # WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids) # AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids - records = records - .where(cte.table[:next_traversal_ids].eq(nil).or(cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids]))) - .where(next_sibling_func(cte.table[:traversal_ids]).gt(namespaces[:traversal_ids])) + records = unscoped + .from([base_cte.table, namespaces]) + .where(base_cte.table[:next_traversal_ids].eq(nil).or(base_cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids]))) + .where(next_sibling_func(base_cte.table[:traversal_ids]).gt(namespaces[:traversal_ids])) # AND base_cte.traversal_ids <= namespaces.traversal_ids - if include_self - records.where(cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) - else - records.where(cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) - end + records = if include_self + records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids])) + else + records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids])) + end + + records_cte = Gitlab::SQL::CTE.new(:descendants_cte, records) + + unscoped + .unscope(where: [:type]) + .with(base_cte.to_arel, records_cte.to_arel) + .from(records_cte.alias_to(namespaces)) end def next_sibling_func(*args) diff --git a/app/models/user.rb b/app/models/user.rb index 942b37c3cae..d5fa9818c79 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -187,8 +187,8 @@ class User < ApplicationRecord has_one :abuse_report, dependent: :destroy, foreign_key: :user_id # rubocop:disable Cop/ActiveRecordDependent has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport" # rubocop:disable Cop/ActiveRecordDependent has_many :spam_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :builds, dependent: :nullify, class_name: 'Ci::Build' # rubocop:disable Cop/ActiveRecordDependent - has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline' # rubocop:disable Cop/ActiveRecordDependent + has_many :builds, class_name: 'Ci::Build' + has_many :pipelines, class_name: 'Ci::Pipeline' has_many :todos has_many :notification_settings has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -911,7 +911,7 @@ class User < ApplicationRecord end def two_factor_u2f_enabled? - return false if Feature.enabled?(:webauthn) + return false if Feature.enabled?(:webauthn, default_enabled: :yaml) if u2f_registrations.loaded? u2f_registrations.any? @@ -925,7 +925,7 @@ class User < ApplicationRecord end def two_factor_webauthn_enabled? - return false unless Feature.enabled?(:webauthn) + return false unless Feature.enabled?(:webauthn, default_enabled: :yaml) (webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?) end @@ -1790,7 +1790,7 @@ class User < ApplicationRecord # we do this on read since migrating all existing users is not a feasible # solution. def feed_token - Gitlab::CurrentSettings.disable_feed_token ? nil : ensure_feed_token! + ensure_feed_token! unless Gitlab::CurrentSettings.disable_feed_token end # Each existing user needs to have a `static_object_token`. @@ -1800,6 +1800,14 @@ class User < ApplicationRecord ensure_static_object_token! end + def enabled_static_object_token + static_object_token if Gitlab::CurrentSettings.static_objects_external_storage_enabled? + end + + def enabled_incoming_email_token + incoming_email_token if Gitlab::IncomingEmail.supports_issue_creation? + end + def sync_attribute?(attribute) return true if ldap_user? && attribute == :email diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index d7221109ecb..f2f97f560e0 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -63,6 +63,12 @@ class MemberEntity < Grape::Entity member.respond_to?(:invited_user_state) ? member.invited_user_state : "" end end + + private + + def current_user + options[:current_user] + end end MemberEntity.prepend_mod_with('MemberEntity') diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 7c1f28345fd..b26ad7d47ab 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -32,62 +32,64 @@ type_plural: type_plural, active_tokens: @active_personal_access_tokens, revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) } +- if Feature.enabled?(:hide_access_tokens) + #js-tokens-app{ data: { tokens_data: tokens_app_data } } +- else + - unless Gitlab::CurrentSettings.disable_feed_token + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_('AccessTokens|Feed token') + %p + = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8.feed-token-reset + = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' + = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true + %p.form-text.text-muted + - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } + = reset_message.html_safe -- unless Gitlab::CurrentSettings.disable_feed_token - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Feed token') - %p - = s_('AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.feed-token-reset - = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold' - = text_field_tag :feed_token, current_user.feed_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.'), testid: :reset_feed_token_link } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } - = reset_message.html_safe + - if incoming_email_token_enabled? + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section + .col-lg-4.profile-settings-sidebar + %h4.gl-mt-0 + = s_('AccessTokens|Incoming email token') + %p + = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8.incoming-email-token-reset + = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' + = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true + %p.form-text.text-muted + - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link } + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } + = reset_message.html_safe -- if incoming_email_token_enabled? - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 - = s_('AccessTokens|Incoming email token') - %p - = s_('AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8.incoming-email-token-reset - = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold' - = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control gl-form-input js-select-on-focus', readonly: true - %p.form-text.text-muted - - reset_link = link_to s_('AccessTokens|reset this token'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.'), testid: :reset_email_token_link } - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}.') % { link_reset_it: reset_link } - = reset_message.html_safe - -- if static_objects_external_storage_enabled? - .col-lg-12 - %hr - .row.gl-mt-3.js-search-settings-section - .col-lg-4 - %h4.gl-mt-0 - = s_('AccessTokens|Static object token') - %p - = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.') - %p - = s_('AccessTokens|It cannot be used to access any other data.') - .col-lg-8 - = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold" - = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()' - %p.form-text.text-muted - - reset_link = url_for [:reset, :static_object_token, :profile] - - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } - - reset_link_end = '</a>'.html_safe - - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } - = reset_message.html_safe + - if static_objects_external_storage_enabled? + .col-lg-12 + %hr + .row.gl-mt-3.js-search-settings-section + .col-lg-4 + %h4.gl-mt-0 + = s_('AccessTokens|Static object token') + %p + = s_('AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.') + %p + = s_('AccessTokens|It cannot be used to access any other data.') + .col-lg-8 + = label_tag :static_object_token, s_('AccessTokens|Static object token'), class: "label-bold" + = text_field_tag :static_object_token, current_user.static_object_token, class: 'form-control gl-form-input', readonly: true, onclick: 'this.select()' + %p.form-text.text-muted + - reset_link = url_for [:reset, :static_object_token, :profile] + - reset_link_start = '<a data-confirm="%{confirm}" rel="nofollow" data-method="put" href="%{url}">'.html_safe % { confirm: s_('AccessTokens|Are you sure?'), url: reset_link } + - reset_link_end = '</a>'.html_safe + - reset_message = s_('AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}.') % { reset_link_start: reset_link_start, reset_link_end: reset_link_end } + = reset_message.html_safe diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 0eae3c95bf6..aae6212f964 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -2,7 +2,7 @@ - page_title _('Two-Factor Authentication'), _('Account') - add_to_breadcrumbs _('Account'), profile_account_path - @content_class = "limit-container-width" unless fluid_layout -- webauthn_enabled = Feature.enabled?(:webauthn) +- webauthn_enabled = Feature.enabled?(:webauthn, default_enabled: :yaml) .js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path } .row.gl-mt-3 diff --git a/app/views/projects/starrers/_starrer.html.haml b/app/views/projects/starrers/_starrer.html.haml index 28ec1ed206a..e24276fcaea 100644 --- a/app/views/projects/starrers/_starrer.html.haml +++ b/app/views/projects/starrers/_starrer.html.haml @@ -13,7 +13,7 @@ %span.cgray= starrer.user.to_reference - if starrer.user == current_user - %span.badge-pill.badge-success.gl-badge.gl-ml-2.sm= _("It's you") + = gl_badge_tag _("It's you"), variant: :success, size: :sm, class: 'gl-ml-2' .block-truncated = time_ago_with_tooltip(starrer.starred_since) diff --git a/bin/metrics-server b/bin/metrics-server index 16c98f65f52..d8f2ed9faa4 100755 --- a/bin/metrics-server +++ b/bin/metrics-server @@ -3,14 +3,10 @@ require_relative '../metrics_server/metrics_server' -begin - target = ENV['METRICS_SERVER_TARGET'] - raise "Required: METRICS_SERVER_TARGET=[sidekiq]" unless target == 'sidekiq' +target = ENV['METRICS_SERVER_TARGET'] +raise "METRICS_SERVER_TARGET cannot be blank" if target.blank? - metrics_dir = ENV["prometheus_multiproc_dir"] || File.absolute_path("tmp/prometheus_multiproc_dir/#{target}") - wipe_metrics_dir = Gitlab::Utils.to_boolean(ENV['WIPE_METRICS_DIR']) || false +metrics_dir = ENV["prometheus_multiproc_dir"] || File.absolute_path("tmp/prometheus_multiproc_dir/#{target}") +wipe_metrics_dir = Gitlab::Utils.to_boolean(ENV['WIPE_METRICS_DIR']) || false - # Re-raise exceptions in threads on the main thread. - Thread.abort_on_exception = true - MetricsServer.new(target, metrics_dir, wipe_metrics_dir).start -end +Process.wait(MetricsServer.spawn(target, metrics_dir: metrics_dir, wipe_metrics_dir: wipe_metrics_dir)) diff --git a/config/feature_flags/development/geo_pages_deployment_verification.yml b/config/feature_flags/development/hide_access_tokens.yml index 3c74d4d3485..dc35cdf0d81 100644 --- a/config/feature_flags/development/geo_pages_deployment_verification.yml +++ b/config/feature_flags/development/hide_access_tokens.yml @@ -1,8 +1,8 @@ --- -name: geo_pages_deployment_verification -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74905 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/346754 +name: hide_access_tokens +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76280 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347490 milestone: '14.6' type: development -group: group::geo +group: group::access default_enabled: false diff --git a/config/feature_flags/development/webauthn.yml b/config/feature_flags/development/webauthn.yml index 0dc9e2b7bfd..135d4af2465 100644 --- a/config/feature_flags/development/webauthn.yml +++ b/config/feature_flags/development/webauthn.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/232671 milestone: '13.4' type: development group: group::access -default_enabled: false +default_enabled: true diff --git a/config/routes/group.rb b/config/routes/group.rb index b0255f1d8fa..0a73a6c0884 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -127,7 +127,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do end namespace :crm do - resources :contacts, only: [:index, :new] + resources :contacts, only: [:index, :new, :edit] resources :organizations, only: [:index] end end diff --git a/db/migrate/20211112073413_change_package_index_on_corpus.rb b/db/migrate/20211112073413_change_package_index_on_corpus.rb new file mode 100644 index 00000000000..6e8222f853c --- /dev/null +++ b/db/migrate/20211112073413_change_package_index_on_corpus.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ChangePackageIndexOnCorpus < Gitlab::Database::Migration[1.0] + INDEX_NAME = 'index_coverage_fuzzing_corpuses_on_package_id' + + disable_ddl_transaction! + + # Changing this index is safe. + # The table does not have any data in it as it's behind a feature flag. + def up + remove_concurrent_index :coverage_fuzzing_corpuses, :package_id, name: INDEX_NAME + add_concurrent_index :coverage_fuzzing_corpuses, :package_id, unique: true, name: INDEX_NAME + end + + def down + remove_concurrent_index :coverage_fuzzing_corpuses, :package_id, name: INDEX_NAME + add_concurrent_index :coverage_fuzzing_corpuses, :package_id, name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20211112073413 b/db/schema_migrations/20211112073413 new file mode 100644 index 00000000000..11551bb5819 --- /dev/null +++ b/db/schema_migrations/20211112073413 @@ -0,0 +1 @@ +8960c0a2b7e621e466fde3bde6a252119008579c058046a16d57a6f6bff42008
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 5b1a7f79ea9..e90d862dfdb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -25870,7 +25870,7 @@ CREATE INDEX index_container_repository_on_name_trigram ON container_repositorie CREATE UNIQUE INDEX index_content_blocked_states_on_container_id_commit_sha_path ON content_blocked_states USING btree (container_identifier, commit_sha, path); -CREATE INDEX index_coverage_fuzzing_corpuses_on_package_id ON coverage_fuzzing_corpuses USING btree (package_id); +CREATE UNIQUE INDEX index_coverage_fuzzing_corpuses_on_package_id ON coverage_fuzzing_corpuses USING btree (package_id); CREATE INDEX index_coverage_fuzzing_corpuses_on_project_id ON coverage_fuzzing_corpuses USING btree (project_id); diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md index 5f98656482c..0a114c04918 100644 --- a/doc/administration/geo/replication/datatypes.md +++ b/doc/administration/geo/replication/datatypes.md @@ -54,8 +54,8 @@ verification methods: | Blobs | External Merge Request Diffs _(file system)_ | Geo with API | SHA256 checksum | | Blobs | External Merge Request Diffs _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | | Blobs | Pipeline artifacts _(file system)_ | Geo with API | SHA256 checksum | -| Blobs | Pipeline artifacts _(object storage)_ | Geo with API/Managed (*2*) | SHA256 checksum | -| Blobs | Pages _(file system)_ | Geo with API | _Not implemented_ | +| Blobs | Pipeline artifacts _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | +| Blobs | Pages _(file system)_ | Geo with API | SHA256 checksum | | Blobs | Pages _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | - (*1*): Redis replication can be used as part of HA with Redis sentinel. It's not used between Geo sites. @@ -147,7 +147,6 @@ these epics/issues: - [Geo: Improve the self-service Geo replication framework](https://gitlab.com/groups/gitlab-org/-/epics/3761) - [Geo: Move existing blobs to framework](https://gitlab.com/groups/gitlab-org/-/epics/3588) - [Geo: Add unreplicated data types](https://gitlab.com/groups/gitlab-org/-/epics/893) -- [Geo: Support GitLab Pages](https://gitlab.com/groups/gitlab-org/-/epics/589) ### Replicated data types behind a feature flag @@ -190,7 +189,7 @@ successfully, you must replicate their data using some other means. |[Project wiki repository](../../../user/project/wiki/) | **Yes** (10.2) | **Yes** (10.7) | No | | |[Group wiki repository](../../../user/project/wiki/group.md) | [**Yes** (13.10)](https://gitlab.com/gitlab-org/gitlab/-/issues/208147) | No | No | Behind feature flag `geo_group_wiki_repository_replication`, enabled by default. | |[Uploads](../../uploads.md) | **Yes** (10.2) | [No](https://gitlab.com/groups/gitlab-org/-/epics/1817) | No | Verified only on transfer or manually using [Integrity Check Rake Task](../../raketasks/check.md) on both sites and comparing the output between them. | -|[LFS objects](../../lfs/index.md) | **Yes** (10.2) | **Yes**(14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | Verified only on transfer or manually using [Integrity Check Rake Task](../../raketasks/check.md) on both sites and comparing the output between them. GitLab versions 11.11.x and 12.0.x are affected by [a bug that prevents any new LFS objects from replicating](https://gitlab.com/gitlab-org/gitlab/-/issues/32696).<br /><br />Replication is behind the feature flag `geo_lfs_object_replication`, enabled by default. Verification is under development behind the feature flag `geo_lfs_object_verification` introduced in 14.6. | +|[LFS objects](../../lfs/index.md) | **Yes** (10.2) | **Yes**(14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | GitLab versions 11.11.x and 12.0.x are affected by [a bug that prevents any new LFS objects from replicating](https://gitlab.com/gitlab-org/gitlab/-/issues/32696).<br /><br />Replication is behind the feature flag `geo_lfs_object_replication`, enabled by default. Verification is behind the feature flag `geo_lfs_object_verification` enabled by default in 14.6. | |[Personal snippets](../../../user/snippets.md) | **Yes** (10.2) | **Yes** (10.2) | No | | |[Project snippets](../../../user/snippets.md) | **Yes** (10.2) | **Yes** (10.2) | No | | |[CI job artifacts](../../../ci/pipelines/job_artifacts.md) | **Yes** (10.4) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/8923) | Via Object Storage provider if supported. Native Geo support (Beta). | Verified only manually using [Integrity Check Rake Task](../../raketasks/check.md) on both sites and comparing the output between them. Job logs also verified on transfer. | @@ -203,7 +202,7 @@ successfully, you must replicate their data using some other means. |[Versioned Terraform State](../../terraform_state.md) | **Yes** (13.5) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (13.12) | Via Object Storage provider if supported. Native Geo support (Beta). | Replication is behind the feature flag `geo_terraform_state_version_replication`, enabled by default. Verification was behind the feature flag `geo_terraform_state_version_verification`, which was removed in 14.0. | |[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification is behind the feature flag `geo_merge_request_diff_verification`, enabled by default in 14.6.| |[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | No | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. | -|[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | No | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_pages_deployment_replication`, enabled by default. | +|[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | [**Yes**](#limitation-of-verification-for-files-in-object-storage) (14.6) | Via Object Storage provider if supported. Native Geo support (Beta). | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification is behind the feature flag `geo_pages_deployment_verification`, enabled by default in 14.6. | |[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | No | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. | |[Elasticsearch integration](../../../integration/elasticsearch.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | Not planned because further product discovery is required and Elasticsearch (ES) clusters can be rebuilt. Secondaries use the same ES cluster as the primary. | |[Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/259694) | No | No | Blocked by [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Replication of this cache is not needed for disaster recovery purposes because it can be recreated from external sources. | diff --git a/doc/api/groups.md b/doc/api/groups.md index c2710e8c92e..13dea42f3c6 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -275,24 +275,24 @@ GET /groups/:id/projects Parameters: -| Attribute | Type | Required | Description | -| ----------------------------- | -------------- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user | -| `archived` | boolean | no | Limit by archived status | -| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | -| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, `similarity` (1), or `last_activity_at` fields. Default is `created_at` | -| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | -| `search` | string | no | Return list of authorized projects matching the search criteria | -| `simple` | boolean | no | Return only the ID, URL, name, and path of each project | -| `owned` | boolean | no | Limit by projects owned by the current user | -| `starred` | boolean | no | Limit by projects starred by the current user | -| `with_issues_enabled` | boolean | no | Limit by projects with issues feature enabled. Default is `false` | -| `with_merge_requests_enabled` | boolean | no | Limit by projects with merge requests feature enabled. Default is `false` | -| `with_shared` | boolean | no | Include projects shared to this group. Default is `true` | -| `include_subgroups` | boolean | no | Include projects in subgroups of this group. Default is `false` | -| `min_access_level` | integer | no | Limit to projects where current user has at least this [access level](members.md#valid-access-levels) | -| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (administrators only) | -| `with_security_reports` | boolean | no | **(ULTIMATE)** Return only projects that have security reports artifacts present in any of their builds. This means "projects with security reports enabled". Default is `false` | +| Attribute | Type | Required | Description | +| -------------------------------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user | +| `archived` | boolean | no | Limit by archived status | +| `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | +| `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, `similarity` (1), or `last_activity_at` fields. Default is `created_at` | +| `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Return list of authorized projects matching the search criteria | +| `simple` | boolean | no | Return only the ID, URL, name, and path of each project | +| `owned` | boolean | no | Limit by projects owned by the current user | +| `starred` | boolean | no | Limit by projects starred by the current user | +| `with_issues_enabled` | boolean | no | Limit by projects with issues feature enabled. Default is `false` | +| `with_merge_requests_enabled` | boolean | no | Limit by projects with merge requests feature enabled. Default is `false` | +| `with_shared` | boolean | no | Include projects shared to this group. Default is `true` | +| `include_subgroups` | boolean | no | Include projects in subgroups of this group. Default is `false` | +| `min_access_level` | integer | no | Limit to projects where current user has at least this [access level](members.md#valid-access-levels) | +| `with_custom_attributes` | boolean | no | Include [custom attributes](custom_attributes.md) in response (administrators only) | +| `with_security_reports` **(ULTIMATE)** | boolean | no | Return only projects that have security reports artifacts present in any of their builds. This means "projects with security reports enabled". Default is `false` | 1. Order by similarity: Orders the results by a similarity score calculated from the provided `search` URL parameter. When using `order_by=similarity`, the `sort` parameter is ignored. When the `search` @@ -783,28 +783,28 @@ POST /groups Parameters: -| Attribute | Type | Required | Description | -| ------------------------------------ | ------- | -------- | ----------- | -| `name` | string | yes | The name of the group. | -| `path` | string | yes | The path of the group. | -| `description` | string | no | The group's description. | -| `membership_lock` | boolean | no | **(PREMIUM)** Prevent adding new members to projects within this group. | -| `visibility` | string | no | The group's visibility. Can be `private`, `internal`, or `public`. | -| `share_with_group_lock` | boolean | no | Prevent sharing a project with another group within this group. | -| `require_two_factor_authentication` | boolean | no | Require all users in this group to setup Two-factor authentication. | -| `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | -| `project_creation_level` | string | no | Determine if developers can create projects in the group. Can be `noone` (No one), `maintainer` (users with the Maintainer role), or `developer` (users with the Developer or Maintainer role). | -| `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | -| `subgroup_creation_level` | string | no | Allowed to [create subgroups](../user/group/subgroups/index.md#creating-a-subgroup). Can be `owner` (Owners), or `maintainer` (users with the Maintainer role). | -| `emails_disabled` | boolean | no | Disable email notifications | -| `avatar` | mixed | no | Image file for avatar of the group. [Introduced in GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/36681) | -| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | -| `lfs_enabled` | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | -| `request_access_enabled` | boolean | no | Allow users to request member access. | -| `parent_id` | integer | no | The parent group ID for creating nested group. | -| `default_branch_protection` | integer | no | See [Options for `default_branch_protection`](#options-for-default_branch_protection). Default to the global level default branch protection setting. | -| `shared_runners_minutes_limit` | integer | no | **(PREMIUM SELF)** Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` | -| `extra_shared_runners_minutes_limit` | integer | no | **(PREMIUM SELF)** Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). | +| Attribute | Type | Required | Description | +| ------------------------------------------------------- | ------- | -------- | ----------- | +| `name` | string | yes | The name of the group. | +| `path` | string | yes | The path of the group. | +| `description` | string | no | The group's description. | +| `membership_lock` **(PREMIUM)** | boolean | no | Prevent adding new members to projects within this group. | +| `visibility` | string | no | The group's visibility. Can be `private`, `internal`, or `public`. | +| `share_with_group_lock` | boolean | no | Prevent sharing a project with another group within this group. | +| `require_two_factor_authentication` | boolean | no | Require all users in this group to setup Two-factor authentication. | +| `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | +| `project_creation_level` | string | no | Determine if developers can create projects in the group. Can be `noone` (No one), `maintainer` (users with the Maintainer role), or `developer` (users with the Developer or Maintainer role). | +| `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | +| `subgroup_creation_level` | string | no | Allowed to [create subgroups](../user/group/subgroups/index.md#creating-a-subgroup). Can be `owner` (Owners), or `maintainer` (users with the Maintainer role). | +| `emails_disabled` | boolean | no | Disable email notifications | +| `avatar` | mixed | no | Image file for avatar of the group. [Introduced in GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/36681) | +| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | +| `lfs_enabled` | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | +| `request_access_enabled` | boolean | no | Allow users to request member access. | +| `parent_id` | integer | no | The parent group ID for creating nested group. | +| `default_branch_protection` | integer | no | See [Options for `default_branch_protection`](#options-for-default_branch_protection). Default to the global level default branch protection setting. | +| `shared_runners_minutes_limit` **(PREMIUM SELF)** | integer | no | Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` | +| `extra_shared_runners_minutes_limit` **(PREMIUM SELF)** | integer | no | Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). | ### Options for `default_branch_protection` @@ -884,32 +884,32 @@ Updates the project group. Only available to group owners and administrators. PUT /groups/:id ``` -| Attribute | Type | Required | Description | -| ------------------------------------------ | ------- | -------- | ----------- | -| `id` | integer | yes | The ID of the group. | -| `name` | string | no | The name of the group. | -| `path` | string | no | The path of the group. | -| `description` | string | no | The description of the group. | -| `membership_lock` | boolean | no | **(PREMIUM)** Prevent adding new members to projects within this group. | -| `share_with_group_lock` | boolean | no | Prevent sharing a project with another group within this group. | -| `visibility` | string | no | The visibility level of the group. Can be `private`, `internal`, or `public`. | -| `require_two_factor_authentication` | boolean | no | Require all users in this group to setup Two-factor authentication. | -| `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | -| `project_creation_level` | string | no | Determine if developers can create projects in the group. Can be `noone` (No one), `maintainer` (users with the Maintainer role), or `developer` (users with the Developer or Maintainer role). | -| `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | -| `subgroup_creation_level` | string | no | Allowed to [create subgroups](../user/group/subgroups/index.md#creating-a-subgroup). Can be `owner` (Owners), or `maintainer` (users with the Maintainer role). | -| `emails_disabled` | boolean | no | Disable email notifications | -| `avatar` | mixed | no | Image file for avatar of the group. [Introduced in GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/36681) | -| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | -| `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | -| `request_access_enabled` | boolean | no | Allow users to request member access. | -| `default_branch_protection` | integer | no | See [Options for `default_branch_protection`](#options-for-default_branch_protection). | -| `file_template_project_id` | integer | no | **(PREMIUM)** The ID of a project to load custom file templates from. | -| `shared_runners_minutes_limit` | integer | no | **(PREMIUM SELF)** Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` | -| `extra_shared_runners_minutes_limit` | integer | no | **(PREMIUM SELF)** Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). | -| `prevent_forking_outside_group` | boolean | no | **(PREMIUM)** When enabled, users can **not** fork projects from this group to external namespaces -| `shared_runners_setting` | string | no | See [Options for `shared_runners_setting`](#options-for-shared_runners_setting). Enable or disable shared runners for a group's subgroups and projects. | -| `prevent_sharing_groups_outside_hierarchy` | boolean | no | See [Prevent group sharing outside the group hierarchy](../user/group/index.md#prevent-group-sharing-outside-the-group-hierarchy). This attribute is only available on top-level groups. [Introduced in GitLab 14.1](https://gitlab.com/gitlab-org/gitlab/-/issues/333721) | +| Attribute | Type | Required | Description | +| ------------------------------------------------------- | ------- | -------- | ----------- | +| `id` | integer | yes | The ID of the group. | +| `name` | string | no | The name of the group. | +| `path` | string | no | The path of the group. | +| `description` | string | no | The description of the group. | +| `membership_lock` **(PREMIUM)** | boolean | no | Prevent adding new members to projects within this group. | +| `share_with_group_lock` | boolean | no | Prevent sharing a project with another group within this group. | +| `visibility` | string | no | The visibility level of the group. Can be `private`, `internal`, or `public`. | +| `require_two_factor_authentication` | boolean | no | Require all users in this group to setup Two-factor authentication. | +| `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | +| `project_creation_level` | string | no | Determine if developers can create projects in the group. Can be `noone` (No one), `maintainer` (users with the Maintainer role), or `developer` (users with the Developer or Maintainer role). | +| `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | +| `subgroup_creation_level` | string | no | Allowed to [create subgroups](../user/group/subgroups/index.md#creating-a-subgroup). Can be `owner` (Owners), or `maintainer` (users with the Maintainer role). | +| `emails_disabled` | boolean | no | Disable email notifications | +| `avatar` | mixed | no | Image file for avatar of the group. [Introduced in GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/36681) | +| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | +| `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | +| `request_access_enabled` | boolean | no | Allow users to request member access. | +| `default_branch_protection` | integer | no | See [Options for `default_branch_protection`](#options-for-default_branch_protection). | +| `file_template_project_id` **(PREMIUM)** | integer | no | The ID of a project to load custom file templates from. | +| `shared_runners_minutes_limit` **(PREMIUM SELF)** | integer | no | Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` | +| `extra_shared_runners_minutes_limit` **(PREMIUM SELF)** | integer | no | Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). | +| `prevent_forking_outside_group` **(PREMIUM)** | boolean | no | When enabled, users can **not** fork projects from this group to external namespaces +| `shared_runners_setting` | string | no | See [Options for `shared_runners_setting`](#options-for-shared_runners_setting). Enable or disable shared runners for a group's subgroups and projects. | +| `prevent_sharing_groups_outside_hierarchy` | boolean | no | See [Prevent group sharing outside the group hierarchy](../user/group/index.md#prevent-group-sharing-outside-the-group-hierarchy). This attribute is only available on top-level groups. [Introduced in GitLab 14.1](https://gitlab.com/gitlab-org/gitlab/-/issues/333721) | NOTE: The `projects` and `shared_projects` attributes in the response are deprecated and [scheduled for removal in API v5](https://gitlab.com/gitlab-org/gitlab/-/issues/213797). diff --git a/doc/api/protected_branches.md b/doc/api/protected_branches.md index a75090c90d5..8497c36a002 100644 --- a/doc/api/protected_branches.md +++ b/doc/api/protected_branches.md @@ -197,18 +197,18 @@ POST /projects/:id/protected_branches curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/protected_branches?name=*-stable&push_access_level=30&merge_access_level=30&unprotect_access_level=40" ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user | -| `name` | string | yes | The name of the branch or wildcard | -| `push_access_level` | string | no | Access levels allowed to push (defaults: `40`, Maintainer role) | -| `merge_access_level` | string | no | Access levels allowed to merge (defaults: `40`, Maintainer role) | -| `unprotect_access_level` | string | no | Access levels allowed to unprotect (defaults: `40`, Maintainer role) | -| `allow_force_push` | boolean | no | Allow all users with push access to force push. (default: `false`) | -| `allowed_to_push` | array | no | **(PREMIUM)** Array of access levels allowed to push, with each described by a hash | -| `allowed_to_merge` | array | no | **(PREMIUM)** Array of access levels allowed to merge, with each described by a hash | -| `allowed_to_unprotect` | array | no | **(PREMIUM)** Array of access levels allowed to unprotect, with each described by a hash | -| `code_owner_approval_required` | boolean | no | **(PREMIUM)** Prevent pushes to this branch if it matches an item in the [`CODEOWNERS` file](../user/project/code_owners.md). (defaults: false) | +| Attribute | Type | Required | Description | +| -------------------------------------------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user | +| `name` | string | yes | The name of the branch or wildcard | +| `push_access_level` | string | no | Access levels allowed to push (defaults: `40`, Maintainer role) | +| `merge_access_level` | string | no | Access levels allowed to merge (defaults: `40`, Maintainer role) | +| `unprotect_access_level` | string | no | Access levels allowed to unprotect (defaults: `40`, Maintainer role) | +| `allow_force_push` | boolean | no | Allow all users with push access to force push. (default: `false`) | +| `allowed_to_push` **(PREMIUM)** | array | no | Array of access levels allowed to push, with each described by a hash | +| `allowed_to_merge` **(PREMIUM)** | array | no | Array of access levels allowed to merge, with each described by a hash | +| `allowed_to_unprotect` **(PREMIUM)** | array | no | Array of access levels allowed to unprotect, with each described by a hash | +| `code_owner_approval_required` **(PREMIUM)** | boolean | no | Prevent pushes to this branch if it matches an item in the [`CODEOWNERS` file](../user/project/code_owners.md). (defaults: false) | Example response: @@ -414,8 +414,8 @@ PATCH /projects/:id/protected_branches/:name curl --request PATCH --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/protected_branches/feature-branch" ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user | -| `name` | string | yes | The name of the branch | -| `code_owner_approval_required` | boolean | no | **(PREMIUM)** Prevent pushes to this branch if it matches an item in the [`CODEOWNERS` file](../user/project/code_owners.md). (defaults: false)| +| Attribute | Type | Required | Description | +| -------------------------------------------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user | +| `name` | string | yes | The name of the branch | +| `code_owner_approval_required` **(PREMIUM)** | boolean | no | Prevent pushes to this branch if it matches an item in the [`CODEOWNERS` file](../user/project/code_owners.md). (defaults: false)| diff --git a/doc/api/settings.md b/doc/api/settings.md index 90f6ca1ec11..e953990c091 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -228,7 +228,7 @@ listed in the descriptions of the relevant settings. | `after_sign_up_text` | string | no | Text shown to the user after signing up. | | `akismet_api_key` | string | required by: `akismet_enabled` | API key for Akismet spam protection. | | `akismet_enabled` | boolean | no | (**If enabled, requires:** `akismet_api_key`) Enable or disable Akismet spam protection. | -| `allow_group_owners_to_manage_ldap` | boolean | no | **(PREMIUM)** Set to `true` to allow group owners to manage LDAP. | +| `allow_group_owners_to_manage_ldap` **(PREMIUM)** | boolean | no | Set to `true` to allow group owners to manage LDAP. | | `allow_local_requests_from_hooks_and_services` | boolean | no | (Deprecated: Use `allow_local_requests_from_web_hooks_and_services` instead) Allow requests to the local network from hooks and services. | | `allow_local_requests_from_system_hooks` | boolean | no | Allow requests to the local network from system hooks. | | `allow_local_requests_from_web_hooks_and_services` | boolean | no | Allow requests to the local network from web hooks and services. | @@ -242,7 +242,7 @@ listed in the descriptions of the relevant settings. | `auto_devops_domain` | string | no | Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages. | | `auto_devops_enabled` | boolean | no | Enable Auto DevOps for projects by default. It automatically builds, tests, and deploys applications based on a predefined CI/CD configuration. | | `automatic_purchased_storage_allocation` | boolean | no | Enabling this permits automatic allocation of purchased storage in a namespace. | -| `check_namespace_plan` | boolean | no | **(PREMIUM)** Enabling this makes only licensed EE features available to projects if the project namespace's plan includes the feature or if the project is public. | +| `check_namespace_plan` **(PREMIUM)** | boolean | no | Enabling this makes only licensed EE features available to projects if the project namespace's plan includes the feature or if the project is public. | | `commit_email_hostname` | string | no | Custom hostname (for private commit emails). | | `container_registry_token_expire_delay` | integer | no | Container Registry token duration in minutes. | | `deactivate_dormant_users` | boolean | no | Enable [automatic deactivation of dormant users](../user/admin_area/moderate_users.md#automatically-deactivate-dormant-users). | @@ -255,8 +255,8 @@ listed in the descriptions of the relevant settings. | `default_project_visibility` | string | no | What visibility level new projects receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | | `default_projects_limit` | integer | no | Project limit per user. Default is `100000`. | | `default_snippet_visibility` | string | no | What visibility level new snippets receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. | -| `delayed_project_deletion` | boolean | no | **(PREMIUM SELF)** Enable delayed project deletion by default in new groups. Default is `false`. | -| `deletion_adjourned_period` | integer | no | **(PREMIUM SELF)** The number of days to wait before deleting a project or group that is marked for deletion. Value must be between 0 and 90. +| `delayed_project_deletion` **(PREMIUM SELF)** | boolean | no | Enable delayed project deletion by default in new groups. Default is `false`. | +| `deletion_adjourned_period` **(PREMIUM SELF)** | integer | no | The number of days to wait before deleting a project or group that is marked for deletion. Value must be between 0 and 90. | `diff_max_patch_bytes` | integer | no | Maximum [diff patch size](../user/admin_area/diff_limits.md), in bytes. | | `diff_max_files` | integer | no | Maximum [files in a diff](../user/admin_area/diff_limits.md). | | `diff_max_lines` | integer | no | Maximum [lines in a diff](../user/admin_area/diff_limits.md). | @@ -273,23 +273,23 @@ listed in the descriptions of the relevant settings. | `eks_account_id` | string | no | Amazon account ID. | | `eks_integration_enabled` | boolean | no | Enable integration with Amazon EKS. | | `eks_secret_access_key` | string | no | AWS IAM secret access key. | -| `elasticsearch_aws_access_key` | string | no | **(PREMIUM)** AWS IAM access key. | -| `elasticsearch_aws_region` | string | no | **(PREMIUM)** The AWS region the Elasticsearch domain is configured. | -| `elasticsearch_aws_secret_access_key` | string | no | **(PREMIUM)** AWS IAM secret access key. | -| `elasticsearch_aws` | boolean | no | **(PREMIUM)** Enable the use of AWS hosted Elasticsearch. | -| `elasticsearch_indexed_field_length_limit` | integer | no | **(PREMIUM)** Maximum size of text fields to index by Elasticsearch. 0 value means no limit. This does not apply to repository and wiki indexing. | -| `elasticsearch_indexed_file_size_limit_kb` | integer | no | **(PREMIUM)** Maximum size of repository and wiki files that are indexed by Elasticsearch. | -| `elasticsearch_indexing` | boolean | no | **(PREMIUM)** Enable Elasticsearch indexing. | -| `elasticsearch_limit_indexing` | boolean | no | **(PREMIUM)** Limit Elasticsearch to index certain namespaces and projects. | -| `elasticsearch_max_bulk_concurrency` | integer | no | **(PREMIUM)** Maximum concurrency of Elasticsearch bulk requests per indexing operation. This only applies to repository indexing operations. | -| `elasticsearch_max_bulk_size_mb` | integer | no | **(PREMIUM)** Maximum size of Elasticsearch bulk indexing requests in MB. This only applies to repository indexing operations. | -| `elasticsearch_namespace_ids` | array of integers | no | **(PREMIUM)** The namespaces to index via Elasticsearch if `elasticsearch_limit_indexing` is enabled. | -| `elasticsearch_project_ids` | array of integers | no | **(PREMIUM)** The projects to index via Elasticsearch if `elasticsearch_limit_indexing` is enabled. | -| `elasticsearch_search` | boolean | no | **(PREMIUM)** Enable Elasticsearch search. | -| `elasticsearch_url` | string | no | **(PREMIUM)** The URL to use for connecting to Elasticsearch. Use a comma-separated list to support cluster (for example, `http://localhost:9200, http://localhost:9201"`). | -| `elasticsearch_username` | string | no | **(PREMIUM)** The `username` of your Elasticsearch instance. | -| `elasticsearch_password` | string | no | **(PREMIUM)** The password of your Elasticsearch instance. | -| `email_additional_text` | string | no | **(PREMIUM)** Additional text added to the bottom of every email for legal/auditing/compliance reasons. | +| `elasticsearch_aws_access_key` **(PREMIUM)** | string | no | AWS IAM access key. | +| `elasticsearch_aws_region` **(PREMIUM)** | string | no | The AWS region the Elasticsearch domain is configured. | +| `elasticsearch_aws_secret_access_key` **(PREMIUM)** | string | no | AWS IAM secret access key. | +| `elasticsearch_aws` **(PREMIUM)** | boolean | no | Enable the use of AWS hosted Elasticsearch. | +| `elasticsearch_indexed_field_length_limit` **(PREMIUM)** | integer | no | Maximum size of text fields to index by Elasticsearch. 0 value means no limit. This does not apply to repository and wiki indexing. | +| `elasticsearch_indexed_file_size_limit_kb` **(PREMIUM)** | integer | no | Maximum size of repository and wiki files that are indexed by Elasticsearch. | +| `elasticsearch_indexing` **(PREMIUM)** | boolean | no | Enable Elasticsearch indexing. | +| `elasticsearch_limit_indexing` **(PREMIUM)** | boolean | no | Limit Elasticsearch to index certain namespaces and projects. | +| `elasticsearch_max_bulk_concurrency` **(PREMIUM)** | integer | no | Maximum concurrency of Elasticsearch bulk requests per indexing operation. This only applies to repository indexing operations. | +| `elasticsearch_max_bulk_size_mb` **(PREMIUM)** | integer | no | Maximum size of Elasticsearch bulk indexing requests in MB. This only applies to repository indexing operations. | +| `elasticsearch_namespace_ids` **(PREMIUM)** | array of integers | no | The namespaces to index via Elasticsearch if `elasticsearch_limit_indexing` is enabled. | +| `elasticsearch_project_ids` **(PREMIUM)** | array of integers | no | The projects to index via Elasticsearch if `elasticsearch_limit_indexing` is enabled. | +| `elasticsearch_search` **(PREMIUM)** | boolean | no | Enable Elasticsearch search. | +| `elasticsearch_url` **(PREMIUM)** | string | no | The URL to use for connecting to Elasticsearch. Use a comma-separated list to support cluster (for example, `http://localhost:9200, http://localhost:9201"`). | +| `elasticsearch_username` **(PREMIUM)** | string | no | The `username` of your Elasticsearch instance. | +| `elasticsearch_password` **(PREMIUM)** | string | no | The password of your Elasticsearch instance. | +| `email_additional_text` **(PREMIUM)** | string | no | Additional text added to the bottom of every email for legal/auditing/compliance reasons. | | `email_author_in_body` | boolean | no | Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead. | | `enabled_git_access_protocol` | string | no | Enabled protocols for Git access. Allowed values are: `ssh`, `http`, and `nil` to allow both protocols. | | `enforce_namespace_storage_limit` | boolean | no | Enabling this permits enforcement of namespace storage limits. | @@ -304,11 +304,11 @@ listed in the descriptions of the relevant settings. | `external_pipeline_validation_service_url` | string | no | URL to use for pipeline validation requests. | | `external_pipeline_validation_service_token` | string | no | Optional. Token to include as the `X-Gitlab-Token` header in requests to the URL in `external_pipeline_validation_service_url`. | | `external_pipeline_validation_service_timeout` | integer | no | How long to wait for a response from the pipeline validation service. Assumes `OK` if it times out. | -| `file_template_project_id` | integer | no | **(PREMIUM)** The ID of a project to load custom file templates from. | +| `file_template_project_id` **(PREMIUM)** | integer | no | The ID of a project to load custom file templates from. | | `first_day_of_week` | integer | no | Start day of the week for calendar views and date pickers. Valid values are `0` (default) for Sunday, `1` for Monday, and `6` for Saturday. | -| `geo_node_allowed_ips` | string | yes | **(PREMIUM)** Comma-separated list of IPs and CIDRs of allowed secondary nodes. For example, `1.1.1.1, 2.2.2.0/24`. | -| `geo_status_timeout` | integer | no | **(PREMIUM)** The amount of seconds after which a request to get a secondary node status times out. | -| `git_two_factor_session_expiry` | integer | no | **(PREMIUM)** Maximum duration (in minutes) of a session for Git operations when 2FA is enabled. | +| `geo_node_allowed_ips` **(PREMIUM)** | string | yes | Comma-separated list of IPs and CIDRs of allowed secondary nodes. For example, `1.1.1.1, 2.2.2.0/24`. | +| `geo_status_timeout` **(PREMIUM)** | integer | no | The amount of seconds after which a request to get a secondary node status times out. | +| `git_two_factor_session_expiry` **(PREMIUM)** | integer | no | Maximum duration (in minutes) of a session for Git operations when 2FA is enabled. | | `gitaly_timeout_default` | integer | no | Default Gitaly timeout, in seconds. This timeout is not enforced for Git fetch/push operations or Sidekiq jobs. Set to `0` to disable timeouts. | | `gitaly_timeout_fast` | integer | no | Gitaly fast operation timeout, in seconds. Some Gitaly operations are expected to be fast. If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' can help maintain the stability of the GitLab instance. Set to `0` to disable timeouts. | | `gitaly_timeout_medium` | integer | no | Medium Gitaly timeout, in seconds. This should be a value between the Fast and the Default timeout. Set to `0` to disable timeouts. | @@ -319,7 +319,7 @@ listed in the descriptions of the relevant settings. | `help_page_hide_commercial_content` | boolean | no | Hide marketing-related entries from help. | | `help_page_support_url` | string | no | Alternate support URL for help page and help dropdown. | | `help_page_text` | string | no | Custom text displayed on the help page. | -| `help_text` | string | no | **(PREMIUM)** GitLab server administrator information. | +| `help_text` **(PREMIUM)** | string | no | GitLab server administrator information. | | `hide_third_party_offers` | boolean | no | Do not display offers from third parties in GitLab. | | `home_page_url` | string | no | Redirect to this URL when not logged in. | | `housekeeping_bitmaps_enabled` | boolean | required by: `housekeeping_enabled` | Enable Git pack file bitmap creation. | @@ -336,21 +336,21 @@ listed in the descriptions of the relevant settings. | `local_markdown_version` | integer | no | Increase this value when any cached Markdown should be invalidated. | | `mailgun_signing_key` | string | no | The Mailgun HTTP webhook signing key for receiving events from webhook. | | `mailgun_events_enabled` | boolean | no | Enable Mailgun event receiver. | -| `maintenance_mode_message` | string | no | **(PREMIUM)** Message displayed when instance is in maintenance mode. | -| `maintenance_mode` | boolean | no | **(PREMIUM)** When instance is in maintenance mode, non-administrative users can sign in with read-only access and make read-only API requests. | +| `maintenance_mode_message` **(PREMIUM)** | string | no | Message displayed when instance is in maintenance mode. | +| `maintenance_mode` **(PREMIUM)** | boolean | no | When instance is in maintenance mode, non-administrative users can sign in with read-only access and make read-only API requests. | | `max_artifacts_size` | integer | no | Maximum artifacts size in MB. | | `max_attachment_size` | integer | no | Limit attachment size in MB. | | `max_import_size` | integer | no | Maximum import size in MB. 0 for unlimited. Default = 0 (unlimited) [Modified](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50MB to 0 in GitLab 13.8. | | `max_pages_size` | integer | no | Maximum size of pages repositories in MB. | -| `max_personal_access_token_lifetime` | integer | no | **(ULTIMATE SELF)** Maximum allowable lifetime for personal access tokens in days. | -| `max_ssh_key_lifetime` | integer | no | **(ULTIMATE SELF)** Maximum allowable lifetime for SSH keys in days. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6. | +| `max_personal_access_token_lifetime` **(ULTIMATE SELF)** | integer | no | Maximum allowable lifetime for personal access tokens in days. | +| `max_ssh_key_lifetime` **(ULTIMATE SELF)** | integer | no | Maximum allowable lifetime for SSH keys in days. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6. | | `metrics_method_call_threshold` | integer | no | A method call is only tracked when it takes longer than the given amount of milliseconds. | | `mirror_available` | boolean | no | Allow repository mirroring to configured by project Maintainers. If disabled, only Administrators can configure repository mirroring. | -| `mirror_capacity_threshold` | integer | no | **(PREMIUM)** Minimum capacity to be available before scheduling more mirrors preemptively. | -| `mirror_max_capacity` | integer | no | **(PREMIUM)** Maximum number of mirrors that can be synchronizing at the same time. | -| `mirror_max_delay` | integer | no | **(PREMIUM)** Maximum time (in minutes) between updates that a mirror can have when scheduled to synchronize. | -| `npm_package_requests_forwarding` | boolean | no | **(PREMIUM)** Use npmjs.org as a default remote repository when the package is not found in the GitLab Package Registry for npm. | -| `pypi_package_requests_forwarding` | boolean | no | **(PREMIUM)** Use pypi.org as a default remote repository when the package is not found in the GitLab Package Registry for PyPI. | +| `mirror_capacity_threshold` **(PREMIUM)** | integer | no | Minimum capacity to be available before scheduling more mirrors preemptively. | +| `mirror_max_capacity` **(PREMIUM)** | integer | no | Maximum number of mirrors that can be synchronizing at the same time. | +| `mirror_max_delay` **(PREMIUM)** | integer | no | Maximum time (in minutes) between updates that a mirror can have when scheduled to synchronize. | +| `npm_package_requests_forwarding` **(PREMIUM)** | boolean | no | Use npmjs.org as a default remote repository when the package is not found in the GitLab Package Registry for npm. | +| `pypi_package_requests_forwarding` **(PREMIUM)** | boolean | no | Use pypi.org as a default remote repository when the package is not found in the GitLab Package Registry for PyPI. | | `outbound_local_requests_whitelist` | array of strings | no | Define a list of trusted domains or IP addresses to which local requests are allowed when local requests for hooks and services are disabled. | `pages_domain_verification_enabled` | boolean | no | Require users to prove ownership of custom domains. Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled. | | `password_authentication_enabled_for_git` | boolean | no | Enable authentication for Git over HTTP(S) via a GitLab account password. Default is `true`. | @@ -365,7 +365,7 @@ listed in the descriptions of the relevant settings. | `project_export_enabled` | boolean | no | Enable project export. | | `prometheus_metrics_enabled` | boolean | no | Enable Prometheus metrics. | | `protected_ci_variables` | boolean | no | CI/CD variables are protected by default. | -| `pseudonymizer_enabled` | boolean | no | **(PREMIUM)** When enabled, GitLab runs a background job that produces pseudonymized CSVs of the GitLab database to upload to your configured object storage directory. +| `pseudonymizer_enabled` **(PREMIUM)** | boolean | no | When enabled, GitLab runs a background job that produces pseudonymized CSVs of the GitLab database to upload to your configured object storage directory. | `push_event_activities_limit` | integer | no | Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push events are created. [Bulk push events are created](../user/admin_area/settings/push_event_activities_limit.md) if it surpasses that value. | | `push_event_hooks_limit` | integer | no | Number of changes (branches or tags) in a single push to determine whether webhooks and services fire or not. Webhooks and services aren't submitted if it surpasses that value. | | `rate_limiting_response_text` | string | no | When rate limiting is enabled via the `throttle_*` settings, send this plain text response when a rate limit is exceeded. 'Retry later' is sent if this is blank. | @@ -375,7 +375,7 @@ listed in the descriptions of the relevant settings. | `recaptcha_site_key` | string | required by: `recaptcha_enabled` | Site key for reCAPTCHA. | | `receive_max_input_size` | integer | no | Maximum push size (MB). | | `repository_checks_enabled` | boolean | no | GitLab periodically runs `git fsck` in all project and wiki repositories to look for silent disk corruption issues. | -| `repository_size_limit` | integer | no | **(PREMIUM)** Size limit per repository (MB) | +| `repository_size_limit` **(PREMIUM)** | integer | no | Size limit per repository (MB) | | `repository_storages_weighted` | hash of strings to integers | no | (GitLab 13.1 and later) Hash of names of taken from `gitlab.yml` to [weights](../administration/repository_storage_paths.md#configure-where-new-repositories-are-stored). New projects are created in one of these stores, chosen by a weighted random selection. | | `repository_storages` | array of strings | no | (GitLab 13.0 and earlier) List of names of enabled storage paths, taken from `gitlab.yml`. New projects are created in one of these stores, chosen at random. | | `require_admin_approval_after_user_signup` | boolean | no | When enabled, any user that signs up for an account using the registration form is placed under a **Pending approval** state and has to be explicitly [approved](../user/admin_area/moderate_users.md) by an administrator. | @@ -385,7 +385,7 @@ listed in the descriptions of the relevant settings. | `send_user_confirmation_email` | boolean | no | Send confirmation email on sign-up. | | `session_expire_delay` | integer | no | Session duration in minutes. GitLab restart is required to apply changes. | | `shared_runners_enabled` | boolean | no | (**If enabled, requires:** `shared_runners_text` and `shared_runners_minutes`) Enable shared runners for new projects. | -| `shared_runners_minutes` | integer | required by: `shared_runners_enabled` | **(PREMIUM)** Set the maximum number of pipeline minutes that a group can use on shared runners per month. | +| `shared_runners_minutes` **(PREMIUM)** | integer | required by: `shared_runners_enabled` | Set the maximum number of pipeline minutes that a group can use on shared runners per month. | | `shared_runners_text` | string | required by: `shared_runners_enabled` | Shared runners text. | | `sidekiq_job_limiter_mode` | string | no | `track` or `compress`. Sets the behavior for [Sidekiq job size limits](../user/admin_area/settings/sidekiq_job_limits.md). Default: 'compress'. | | `sidekiq_job_limiter_compression_threshold_bytes` | integer | no | The threshold in bytes at which Sidekiq jobs are compressed before being stored in Redis. Default: 100 000 bytes (100KB). | @@ -393,10 +393,10 @@ listed in the descriptions of the relevant settings. | `sign_in_text` | string | no | Text on the login page. | | `signin_enabled` | string | no | (Deprecated: Use `password_authentication_enabled_for_web` instead) Flag indicating if password authentication is enabled for the web interface. | | `signup_enabled` | boolean | no | Enable registration. Default is `true`. | -| `slack_app_enabled` | boolean | no | **(PREMIUM)** (**If enabled, requires:** `slack_app_id`, `slack_app_secret` and `slack_app_secret`) Enable Slack app. | -| `slack_app_id` | string | required by: `slack_app_enabled` | **(PREMIUM)** The app ID of the Slack-app. | -| `slack_app_secret` | string | required by: `slack_app_enabled` | **(PREMIUM)** The app secret of the Slack-app. | -| `slack_app_verification_token` | string | required by: `slack_app_enabled` | **(PREMIUM)** The verification token of the Slack-app. | +| `slack_app_enabled` **(PREMIUM)** | boolean | no | (**If enabled, requires:** `slack_app_id`, `slack_app_secret` and `slack_app_secret`) Enable Slack app. | +| `slack_app_id` **(PREMIUM)** | string | required by: `slack_app_enabled` | The app ID of the Slack-app. | +| `slack_app_secret` **(PREMIUM)** | string | required by: `slack_app_enabled` | The app secret of the Slack-app. | +| `slack_app_verification_token` **(PREMIUM)** | string | required by: `slack_app_enabled` | The verification token of the Slack-app. | | `snippet_size_limit` | integer | no | Max snippet content size in **bytes**. Default: 52428800 Bytes (50MB).| | `snowplow_app_id` | string | no | The Snowplow site name / application ID. (for example, `gitlab`) | | `snowplow_collector_hostname` | string | required by: `snowplow_enabled` | The Snowplow collector hostname. (for example, `snowplow.trx.gitlab.net`) | diff --git a/doc/development/documentation/restful_api_styleguide.md b/doc/development/documentation/restful_api_styleguide.md index 03980a42381..5dc627c93e9 100644 --- a/doc/development/documentation/restful_api_styleguide.md +++ b/doc/development/documentation/restful_api_styleguide.md @@ -1,9 +1,6 @@ --- -type: reference, dev -stage: none -group: Development -info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines" -description: "Writing styles, markup, formatting, and other standards for the GitLab RESTful APIs." +info: For assistance with this Style Guide page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-other-projects-and-subjects. +description: 'Writing styles, markup, formatting, and other standards for the GitLab RESTful APIs.' --- # RESTful API @@ -30,6 +27,10 @@ In the Markdown doc for a resource (AKA endpoint): - Every method must have a detailed [description of the parameters](#method-description). - Every method must have a cURL example. - Every method must have a response body (in JSON format). +- If an attribute is available only to higher level tiers than the other + parameters, add the appropriate inline [tier badge](styleguide/index.md#product-tier-badges). + Put the badge in the **Attribute** column, like the + `**(<tier>)**` code in the following template. ## API topic template @@ -49,12 +50,12 @@ METHOD /endpoint Supported attributes: -| Attribute | Type | Required | Description | -| :---------- | :------- | :--------------------- | :-------------------- | -| `attribute` | datatype | **{check-circle}** Yes | Detailed description. | -| `attribute` | datatype | **{dotted-circle}** No | Detailed description. | -| `attribute` | datatype | **{dotted-circle}** No | Detailed description. | -| `attribute` | datatype | **{dotted-circle}** No | Detailed description. | +| Attribute | Type | Required | Description | +|:-------------------------|:---------|:-----------------------|:----------------------| +| `attribute` | datatype | **{check-circle}** Yes | Detailed description. | +| `attribute` **(<tier>)** | datatype | **{dotted-circle}** No | Detailed description. | +| `attribute` | datatype | **{dotted-circle}** No | Detailed description. | +| `attribute` | datatype | **{dotted-circle}** No | Detailed description. | Example request: @@ -83,20 +84,20 @@ always be in code blocks using backticks (`` ` ``). Sort the attributes in the table: first, required, then alphabetically. ```markdown -| Attribute | Type | Required | Description | -| :------------- | :------------ | :--------------------- | :--------------------------------------------------- | -| `user` | string | **{check-circle}** Yes | The GitLab username. | -| `assignee_ids` | integer array | **{dotted-circle}** No | The IDs of the users to assign the issue to. | -| `confidential` | boolean | **{dotted-circle}** No | Set an issue to be confidential. Default is `false`. | +| Attribute | Type | Required | Description | +|:-----------------------------|:--------------|:-----------------------|:-----------------------------------------------------| +| `user` | string | **{check-circle}** Yes | The GitLab username. | +| `assignee_ids` **(PREMIUM)** | integer array | **{dotted-circle}** No | The IDs of the users to assign the issue to. | +| `confidential` | boolean | **{dotted-circle}** No | Set an issue to be confidential. Default is `false`. | ``` Rendered example: -| Attribute | Type | Required | Description | -| :------------- | :------------ | :--------------------- | :--------------------------------------------------- | -| `user` | string | **{check-circle}** Yes | The GitLab username. | -| `assignee_ids` | integer array | **{dotted-circle}** No | The IDs of the users to assign the issue to. | -| `confidential` | boolean | **{dotted-circle}** No | Set an issue to be confidential. Default is `false`. | +| Attribute | Type | Required | Description | +|:-----------------------------|:--------------|:-----------------------|:-----------------------------------------------------| +| `user` | string | **{check-circle}** Yes | The GitLab username. | +| `assignee_ids` **(PREMIUM)** | integer array | **{dotted-circle}** No | The IDs of the users to assign the issue to. | +| `confidential` | boolean | **{dotted-circle}** No | Set an issue to be confidential. Default is `false`. | ## cURL commands @@ -109,7 +110,7 @@ Rendered example: username and password. | Methods | Description | -| :---------------------------------------------- | :----------------------------------------------------- | +|:------------------------------------------------|:-------------------------------------------------------| | `--header "PRIVATE-TOKEN: <your_access_token>"` | Use this method as is, whenever authentication needed. | | `--request POST` | Use this method when creating new objects | | `--request PUT` | Use this method when updating existing objects | diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md index 29dfca03cbb..bbbb8dc556a 100644 --- a/doc/development/documentation/styleguide/index.md +++ b/doc/development/documentation/styleguide/index.md @@ -1806,7 +1806,9 @@ after the heading text. For example: # Heading title **(FREE)** ``` -Do not add tier badges inline with other text. The single source of truth for a feature should be the heading where the functionality is described. +Do not add tier badges inline with other text, except for [API attributes](../restful_api_styleguide.md). +The single source of truth for a feature should be the heading where the +functionality is described. #### Available product tier badges diff --git a/doc/push_rules/push_rules.md b/doc/push_rules/push_rules.md index 84a6bb4c2a6..425275a0370 100644 --- a/doc/push_rules/push_rules.md +++ b/doc/push_rules/push_rules.md @@ -107,19 +107,19 @@ The following options are available: | Push rule | Description | |---------------------------------|-------------| | Removal of tags with `git push` | Forbid users to remove Git tags with `git push`. Tags can be deleted through the web UI. | -| Check whether the commit author is a GitLab user | Restrict commits to existing GitLab users (checked against their emails). | -| Reject unverified users | GitLab rejects any commit that was not committed by an authenticated user. | +| Check whether the commit author is a GitLab user | Restrict commits to existing GitLab users (checked against their emails). <sup>1</sup> | +| Reject unverified users | GitLab rejects any commit that was not committed by the same user as the user who pushed it, or where the committer's email address is not [confirmed](../security/user_email_confirmation.md). | | Check whether commit is signed through GPG | Reject commit when it is not signed through GPG. Read [signing commits with GPG](../user/project/repository/gpg_signed_commits/index.md). | | Prevent pushing secret files | GitLab rejects any files that are likely to contain secrets. See the [forbidden file names](#prevent-pushing-secrets-to-the-repository). | -| Require expression in commit messages | Only commit messages that match this regular expression are allowed to be pushed. Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. | -| Reject expression in commit messages | Only commit messages that do not match this regular expression are allowed to be pushed. Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. | -| Restrict by branch name | Only branch names that match this regular expression are allowed to be pushed. Leave empty to allow all branch names. | -| Restrict by commit author's email | Only commit author's email that match this regular expression are allowed to be pushed. Leave empty to allow any email. | -| Prohibited file names | Any committed filenames that match this regular expression and do not already exist in the repository are not allowed to be pushed. Leave empty to allow any filenames. See [common examples](#prohibited-file-names). | +| Require expression in commit messages | Only commit messages that match this regular expression are allowed to be pushed. <sup>2</sup> Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. | +| Reject expression in commit messages | Only commit messages that do not match this regular expression are allowed to be pushed. <sup>2</sup> Leave empty to allow any commit message. Uses multiline mode, which can be disabled using `(?-m)`. | +| Restrict by branch name | Only branch names that match this regular expression are allowed to be pushed. <sup>2</sup> Leave empty to allow all branch names. | +| Restrict by commit author's email | Only commit author's email that match this regular expression are allowed to be pushed. <sup>1</sup> <sup>2</sup> Leave empty to allow any email. | +| Prohibited file names | Any committed filenames that match this regular expression and do not already exist in the repository are not allowed to be pushed. <sup>2</sup> Leave empty to allow any filenames. See [common examples](#prohibited-file-names). | | Maximum file size | Pushes that contain added or updated files that exceed this file size (in MB) are rejected. Set to 0 to allow files of any size. Files tracked by Git LFS are exempted. | -NOTE: -GitLab uses [RE2 syntax](https://github.com/google/re2/wiki/Syntax) for regular expressions in push rules, and you can test them at the [regex101 regex tester](https://regex101.com/). +1. Checks both the commit author and committer. +1. GitLab uses [RE2 syntax](https://github.com/google/re2/wiki/Syntax) for regular expressions in push rules, and you can test them at the [regex101 regex tester](https://regex101.com/). ### Caveat to "Reject unsigned commits" push rule diff --git a/doc/subscriptions/self_managed/index.md b/doc/subscriptions/self_managed/index.md index aee18e3d763..94180da2bbd 100644 --- a/doc/subscriptions/self_managed/index.md +++ b/doc/subscriptions/self_managed/index.md @@ -283,6 +283,30 @@ It also displays the following important statistics: | Maximum users | The highest number of billable users on your system during the term of the loaded license. | | Users over license | Calculated as `Maximum users` - `Users in License` for the current license term. This number incurs a retroactive charge that needs to be paid for at renewal. | +## Export your license usage + +> Introduced in GitLab 14.6. + +If you are an administrator, you can export your license usage into a CSV: + +1. On the top bar, select **Menu > Admin**. +1. On the left sidebar, select **Subscription**. +1. In the top right, select **Export license usage file**. + +This file contains all the information GitLab needs to manually process quarterly reconciliations or renewals. If your instance is firewalled or air-gapped, you can provide GitLab with this information. + +The **License Usage** CSV includes the following details: + +- License key +- Email +- License start date +- License end date +- Company +- Generated at (the timestamp for when the file was exported) +- Table of historical user counts for each day in the period: + - Date the count was recorded + - Active user count + ## Renew your subscription To renew your subscription, diff --git a/doc/user/admin_area/settings/account_and_limit_settings.md b/doc/user/admin_area/settings/account_and_limit_settings.md index a6ad311da38..5868f20d0d8 100644 --- a/doc/user/admin_area/settings/account_and_limit_settings.md +++ b/doc/user/admin_area/settings/account_and_limit_settings.md @@ -75,21 +75,21 @@ If you choose a size larger than the configured value for the web server, you may receive errors. See the [troubleshooting section](#troubleshooting) for more details. -## Personal Access Token prefix +## Personal access token prefix -> [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/342327) in GitLab 14.5. Default prefix added. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20968) in GitLab 13.7. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/342327) in GitLab 14.5, a default prefix. -You can set a global prefix for all generated Personal Access Tokens. +You can specify a prefix for personal access tokens. You might use a prefix +to find tokens more quickly, or for use with automation tools. -A prefix can help you identify PATs visually, as well as with automation tools. +The default prefix is `glpat-` but administrators can change it. -NOTE: -For GitLab.com and self-managed instances, the default prefix is `glpat-`. +[Project access tokens](../../project/settings/project_access_tokens.md) also inherit this prefix. ### Set a prefix -Only a GitLab administrator can set the prefix, which is a global setting applied -to any PAT generated in the system by any user: +To change the default global prefix: 1. On the top bar, select **Menu > Admin**. 1. On the left sidebar, select **Settings > General**. @@ -97,8 +97,8 @@ to any PAT generated in the system by any user: 1. Fill in the **Personal Access Token prefix** field. 1. Click **Save changes**. -It is also possible to configure the prefix via the [settings API](../../../api/settings.md) -using the `personal_access_token_prefix` field. +You can also configure the prefix by using the +[settings API](../../../api/settings.md). ## Repository size limit **(PREMIUM SELF)** diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md index 92fcf0f76e0..c5d91f709dd 100644 --- a/doc/user/application_security/container_scanning/index.md +++ b/doc/user/application_security/container_scanning/index.md @@ -168,6 +168,36 @@ container_scanning: CS_DISABLE_DEPENDENCY_LIST: "true" ``` +#### Report language-specific findings + +> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/7277) in GitLab 14.6. + +The `CS_DISABLE_LANGUAGE_VULNERABILITY_SCAN` CI/CD variable controls whether the scan reports +findings related to programming languages. The languages supported depend on the +[scanner used](#change-scanners): + +- [Trivy](https://aquasecurity.github.io/trivy/latest/vulnerability/detection/language/). +- [Grype](https://github.com/anchore/grype#features). + +By default, the report only includes packages managed by the Operating System (OS) package manager +(for example, `yum`, `apt`, `apk`, `tdnf`). To report security findings in non-OS packages, set +`CS_DISABLE_LANGUAGE_VULNERABILITY_SCAN` to `"false"`: + +```yaml +include: + - template: Security/Container-Scanning.gitlab-ci.yml + +container_scanning: + variables: + CS_DISABLE_LANGUAGE_VULNERABILITY_SCAN: "false" +``` + +When you enable this feature, you may see [duplicate findings](../terminology/#duplicate-finding) +in the [Vulnerability Report](../vulnerability_report/) +if [Dependency Scanning](../dependency_scanning/) +is enabled for your project. This happens because GitLab can't automatically deduplicate the +findings reported by the two different analyzers. + #### Available CI/CD variables You can [configure](#customizing-the-container-scanning-settings) analyzers by using the following CI/CD variables: diff --git a/doc/user/permissions.md b/doc/user/permissions.md index eb79d5099eb..59ab5523ddd 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -119,7 +119,7 @@ The following table lists project permissions available for each role: | [Merge requests](project/merge_requests/index.md):<br>Apply code change suggestions | | | ✓ | ✓ | ✓ | | [Merge requests](project/merge_requests/index.md):<br>Approve (*9*) | | | ✓ | ✓ | ✓ | | [Merge requests](project/merge_requests/index.md):<br>Assign | | | ✓ | ✓ | ✓ | -| [Merge requests](project/merge_requests/index.md):<br>Create | | | ✓ | ✓ | ✓ | +| [Merge requests](project/merge_requests/index.md):<br>Create (*18*) | | | ✓ | ✓ | ✓ | | [Merge requests](project/merge_requests/index.md):<br>Add labels | | | ✓ | ✓ | ✓ | | [Merge requests](project/merge_requests/index.md):<br>Lock threads | | | ✓ | ✓ | ✓ | | [Merge requests](project/merge_requests/index.md):<br>Manage or accept | | | ✓ | ✓ | ✓ | @@ -233,6 +233,7 @@ The following table lists project permissions available for each role: 1. Guest users can only set metadata (for example, labels, assignees, or milestones) when creating an issue. They cannot change the metadata on existing issues. 1. In GitLab 14.5 or later, Guests are not allowed to [create incidents](../operations/incident_management/incidents.md#incident-creation). +1. In projects that accept contributions from external members, users can create, edit, and close their own merge requests. ## Project features permissions diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md index da0fc96714c..343f8e328ba 100644 --- a/doc/user/profile/account/two_factor_authentication.md +++ b/doc/user/profile/account/two_factor_authentication.md @@ -20,8 +20,7 @@ password secret. NOTE: When you enable 2FA, don't forget to back up your [recovery codes](#recovery-codes)! -In addition to time-based one time passwords (TOTP), GitLab supports U2F -(universal 2nd factor) and WebAuthn (experimental) devices as the second factor +In addition to time-based one time passwords (TOTP), GitLab supports WebAuthn devices as the second factor of authentication. After being enabled, in addition to supplying your username and password to sign in, you're prompted to activate your U2F / WebAuthn device (usually by pressing a button on it) which performs secure authentication on @@ -269,11 +268,11 @@ Click on **Register U2F Device** to complete the process. ### WebAuthn device -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/22506) in GitLab 13.4. -> - It's [deployed behind a feature flag](../../feature_flags.md), disabled by default. -> - It's disabled on GitLab.com. -> - It's not recommended for production use. -> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-webauthn). +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/22506) in GitLab 13.4 [with a flag](../../../administration/feature_flags.md) named `webauthn`. Disabled by default. +> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/232671) in GitLab 14.6. + +FLAG: +On self-managed GitLab, by default this feature is available. To disable the feature, ask an administrator to [disable the feature flag](../../../administration/feature_flags.md) named `webauthn`. If you disable the WebAuthn feature flag after WebAuthn devices have been registered, these devices are not usable until you re-enable this feature. On GitLab.com, this feature is available. The WebAuthn workflow is [supported by](https://caniuse.com/#search=webauthn) the following desktop browsers: @@ -350,7 +349,7 @@ request, and you're automatically signed in. ### Sign in by using a WebAuthn device In supported browsers you should be automatically prompted to activate your WebAuthn device -(for example, by touching/pressing its button) after entering your credentials. +(for example, by touching or pressing its button) after entering your credentials. A message displays, indicating that your device responded to the authentication request and you're automatically signed in. @@ -495,25 +494,6 @@ request a GitLab global administrator disable two-factor authentication for your - To enforce 2FA at the system or group levels see [Enforce Two-factor Authentication](../../../security/two_factor_authentication.md). -## Enable or disable WebAuthn **(FREE SELF)** - -Support for WebAuthn is under development and not ready for production use. It is -deployed behind a feature flag that is **disabled by default**. -[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) -can enable it. - -To enable it: - -```ruby -Feature.enable(:webauthn) -``` - -To disable it: - -```ruby -Feature.disable(:webauthn) -``` - ## Troubleshooting If you are receiving an `invalid pin code` error, this may indicate that there is a time sync issue between the authentication application and the GitLab instance itself. diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 54b97eb5732..8222d696853 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -74,7 +74,7 @@ change and whether you need access to a development environment: If you decide to permanently stop work on a merge request, GitLab recommends you close the merge request rather than -[delete it](#delete-a-merge-request). Users with +[delete it](#delete-a-merge-request). The author and assignees of a merge request, and users with Developer, Maintainer, or Owner [roles](../../permissions.md) in a project can close merge requests in the project: diff --git a/doc/user/project/settings/project_access_tokens.md b/doc/user/project/settings/project_access_tokens.md index a831e4b3460..44ece6cb172 100644 --- a/doc/user/project/settings/project_access_tokens.md +++ b/doc/user/project/settings/project_access_tokens.md @@ -32,6 +32,9 @@ You can use project access tokens: - Consider [disabling project access tokens](#enable-or-disable-project-access-token-creation) to lower potential abuse. +Project access tokens inherit the [default prefix setting](../../admin_area/settings/account_and_limit_settings.md#personal-access-token-prefix) +configured for personal access tokens. + ## Create a project access token To create a project access token: diff --git a/lib/gitlab/database/gitlab_loose_foreign_keys.yml b/lib/gitlab/database/gitlab_loose_foreign_keys.yml index 3abd2023f14..0343c054f23 100644 --- a/lib/gitlab/database/gitlab_loose_foreign_keys.yml +++ b/lib/gitlab/database/gitlab_loose_foreign_keys.yml @@ -22,6 +22,20 @@ ci_namespace_mirrors: - table: namespaces column: namespace_id on_delete: async_delete +ci_builds: + - table: users + column: user_id + on_delete: async_nullify +ci_pipelines: + - table: merge_requests + column: merge_request_id + on_delete: async_delete + - table: external_pull_requests + column: external_pull_request_id + on_delete: async_nullify + - table: users + column: user_id + on_delete: async_nullify ci_project_mirrors: - table: projects column: project_id @@ -49,3 +63,7 @@ merge_request_metrics: - table: ci_pipelines column: pipeline_id on_delete: async_delete +project_pages_metadata: + - table: ci_job_artifacts + column: artifacts_archive_id + on_delete: async_nullify diff --git a/lib/gitlab/process_management.rb b/lib/gitlab/process_management.rb index 604b6129648..25a198e4a6a 100644 --- a/lib/gitlab/process_management.rb +++ b/lib/gitlab/process_management.rb @@ -2,12 +2,6 @@ module Gitlab module ProcessManagement - # The signals that should terminate both the master and workers. - TERMINATE_SIGNALS = %i(INT TERM).freeze - - # The signals that should simply be forwarded to the workers. - FORWARD_SIGNALS = %i(TTIN USR1 USR2 HUP).freeze - # Traps the given signals and yields the block whenever these signals are # received. # @@ -26,12 +20,13 @@ module Gitlab end end - def self.trap_terminate(&block) - trap_signals(TERMINATE_SIGNALS, &block) - end - - def self.trap_forward(&block) - trap_signals(FORWARD_SIGNALS, &block) + # Traps the given signals with the given command. + # + # Example: + # + # modify_signals(%i(HUP TERM), 'DEFAULT') + def self.modify_signals(signals, command) + signals.each { |signal| trap(signal, command) } end def self.signal(pid, signal) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3e1ebdf0fa2..eb19ccf6a07 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1805,6 +1805,15 @@ msgstr "" msgid "AccessTokens|Are you sure? Any issue email addresses currently in use will stop working." msgstr "" +msgid "AccessTokens|Copy feed token" +msgstr "" + +msgid "AccessTokens|Copy incoming email token" +msgstr "" + +msgid "AccessTokens|Copy static object token" +msgstr "" + msgid "AccessTokens|Created" msgstr "" @@ -1817,12 +1826,21 @@ msgstr "" msgid "AccessTokens|It cannot be used to access any other data." msgstr "" +msgid "AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{linkStart}reset this token%{linkEnd}." +msgstr "" + msgid "AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{reset_link_start}reset this token%{reset_link_end}." msgstr "" +msgid "AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}." +msgstr "" + msgid "AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{link_reset_it}." msgstr "" +msgid "AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}." +msgstr "" + msgid "AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{link_reset_it}." msgstr "" @@ -10264,6 +10282,9 @@ msgstr "" msgid "Crm|Contact has been added" msgstr "" +msgid "Crm|Contact has been updated" +msgstr "" + msgid "Crm|Create new contact" msgstr "" @@ -10273,6 +10294,9 @@ msgstr "" msgid "Crm|Description (optional)" msgstr "" +msgid "Crm|Edit contact" +msgstr "" + msgid "Crm|Email" msgstr "" @@ -10282,9 +10306,6 @@ msgstr "" msgid "Crm|Last name" msgstr "" -msgid "Crm|New Contact" -msgstr "" - msgid "Crm|New contact" msgstr "" diff --git a/metrics_server/dependencies.rb b/metrics_server/dependencies.rb index 80e68b95ed1..a459efef1ad 100644 --- a/metrics_server/dependencies.rb +++ b/metrics_server/dependencies.rb @@ -22,5 +22,6 @@ require_relative '../lib/gitlab/metrics/exporter/base_exporter' require_relative '../lib/gitlab/metrics/exporter/sidekiq_exporter' require_relative '../lib/gitlab/health_checks/probes/collection' require_relative '../lib/gitlab/health_checks/probes/status' +require_relative '../lib/gitlab/process_management' # rubocop:enable Naming/FileName diff --git a/metrics_server/metrics_server.rb b/metrics_server/metrics_server.rb index 9dc3ba91536..56fc20dcc9d 100644 --- a/metrics_server/metrics_server.rb +++ b/metrics_server/metrics_server.rb @@ -6,17 +6,28 @@ require_relative 'dependencies' class MetricsServer # rubocop:disable Gitlab/NamespacedClass class << self - def spawn(target, gitlab_config: nil, wipe_metrics_dir: false) - cmd = "#{Rails.root}/bin/metrics-server" - env = { - 'METRICS_SERVER_TARGET' => target, - 'GITLAB_CONFIG' => gitlab_config, - 'WIPE_METRICS_DIR' => wipe_metrics_dir.to_s - } - - Process.spawn(env, cmd, err: $stderr, out: $stdout).tap do |pid| + def spawn(target, metrics_dir:, wipe_metrics_dir: false, trapped_signals: []) + raise "The only valid target is 'sidekiq' currently" unless target == 'sidekiq' + + pid = Process.fork + + if pid.nil? # nil means we're inside the fork + # Remove any custom signal handlers the parent process had registered, since we do + # not want to inherit them, and Ruby forks with a `clone` that has the `CLONE_SIGHAND` + # flag set. + Gitlab::ProcessManagement.modify_signals(trapped_signals, 'DEFAULT') + + server = MetricsServer.new(target, metrics_dir, wipe_metrics_dir) + # This rewrites /proc/cmdline, since otherwise tools like `top` will show the + # parent process `cmdline` which is really confusing. + $0 = server.name + + server.start + else Process.detach(pid) end + + pid end end @@ -34,10 +45,15 @@ class MetricsServer # rubocop:disable Gitlab/NamespacedClass FileUtils.mkdir_p(@metrics_dir, mode: 0700) ::Prometheus::CleanupMultiprocDirService.new.execute if @wipe_metrics_dir - settings = Settings.monitoring.sidekiq_exporter + settings = Settings.new(Settings.monitoring[name]) + exporter_class = "Gitlab::Metrics::Exporter::#{@target.camelize}Exporter".constantize server = exporter_class.instance(settings, synchronous: true) server.start end + + def name + "#{@target}_exporter" + end end diff --git a/package.json b/package.json index b2fc03fc773..f16af918306 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,9 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", - "@gitlab/svgs": "1.226.0", + "@gitlab/svgs": "1.229.0", "@gitlab/tributejs": "1.0.0", - "@gitlab/ui": "32.43.2", + "@gitlab/ui": "32.49.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "6.1.4-1", "@rails/ujs": "6.1.4-1", @@ -115,7 +115,7 @@ "codesandbox-api": "0.0.23", "compression-webpack-plugin": "^5.0.2", "copy-webpack-plugin": "^6.4.1", - "core-js": "^3.19.3", + "core-js": "^3.20.0", "cron-validator": "^1.1.1", "cronstrue": "^1.122.0", "cropper": "^2.3.0", @@ -129,7 +129,7 @@ "dompurify": "^2.3.4", "dropzone": "^4.2.0", "editorconfig": "^0.15.3", - "emoji-regex": "^7.0.3", + "emoji-regex": "^10.0.0", "fast-mersenne-twister": "1.0.2", "file-loader": "^6.2.0", "fuzzaldrin-plus": "^0.6.0", diff --git a/sidekiq_cluster/cli.rb b/sidekiq_cluster/cli.rb index 274b7c03e14..57649ec74c8 100644 --- a/sidekiq_cluster/cli.rb +++ b/sidekiq_cluster/cli.rb @@ -20,6 +20,14 @@ require_relative 'sidekiq_cluster' module Gitlab module SidekiqCluster class CLI + THREAD_NAME = 'supervisor' + + # The signals that should terminate both the master and workers. + TERMINATE_SIGNALS = %i(INT TERM).freeze + + # The signals that should simply be forwarded to the workers. + FORWARD_SIGNALS = %i(TTIN USR1 USR2 HUP).freeze + CommandError = Class.new(StandardError) def initialize(log_output = $stderr) @@ -27,6 +35,7 @@ module Gitlab @max_concurrency = 50 @min_concurrency = 0 @environment = ENV['RAILS_ENV'] || 'development' + @metrics_dir = ENV["prometheus_multiproc_dir"] || File.absolute_path("tmp/prometheus_multiproc_dir/sidekiq") @pid = nil @interval = 5 @alive = true @@ -39,6 +48,8 @@ module Gitlab end def run(argv = ARGV) + Thread.current.name = THREAD_NAME + if argv.empty? raise CommandError, 'You must specify at least one queue to start a worker for' @@ -144,13 +155,13 @@ module Gitlab end def trap_signals - ProcessManagement.trap_terminate do |signal| + ProcessManagement.trap_signals(TERMINATE_SIGNALS) do |signal| @alive = false ProcessManagement.signal_processes(@processes, signal) wait_for_termination end - ProcessManagement.trap_forward do |signal| + ProcessManagement.trap_signals(FORWARD_SIGNALS) do |signal| ProcessManagement.signal_processes(@processes, signal) end end @@ -180,7 +191,12 @@ module Gitlab return unless metrics_server_enabled? @logger.info("Starting metrics server on port #{sidekiq_exporter_port}") - @metrics_server_pid = MetricsServer.spawn('sidekiq', wipe_metrics_dir: wipe_metrics_dir) + @metrics_server_pid = MetricsServer.spawn( + 'sidekiq', + metrics_dir: @metrics_dir, + wipe_metrics_dir: wipe_metrics_dir, + trapped_signals: TERMINATE_SIGNALS + FORWARD_SIGNALS + ) end def sidekiq_exporter_enabled? diff --git a/spec/commands/metrics_server/metrics_server_spec.rb b/spec/commands/metrics_server/metrics_server_spec.rb index 7a03ff3fe75..f3936e6b346 100644 --- a/spec/commands/metrics_server/metrics_server_spec.rb +++ b/spec/commands/metrics_server/metrics_server_spec.rb @@ -29,17 +29,27 @@ RSpec.describe 'bin/metrics-server', :aggregate_failures do config_file.write(YAML.dump(config)) config_file.close - @pid = MetricsServer.spawn('sidekiq', gitlab_config: config_file.path, wipe_metrics_dir: true) + + env = { + 'GITLAB_CONFIG' => config_file.path, + 'METRICS_SERVER_TARGET' => 'sidekiq', + 'WIPE_METRICS_DIR' => '1' + } + @pid = Process.spawn(env, 'bin/metrics-server', pgroup: true) end after do webmock_enable! if @pid + pgrp = Process.getpgid(@pid) + Timeout.timeout(5) do - Process.kill('TERM', @pid) + Process.kill('TERM', -pgrp) Process.waitpid(@pid) end + + expect(Gitlab::ProcessManagement.process_alive?(@pid)).to be(false) end rescue Errno::ESRCH => _ # 'No such process' means the process died before diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb index c0576608088..148b8720740 100644 --- a/spec/commands/sidekiq_cluster/cli_spec.rb +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -258,6 +258,17 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath end context 'metrics server' do + let(:trapped_signals) { described_class::TERMINATE_SIGNALS + described_class::FORWARD_SIGNALS } + let(:metrics_dir) { Dir.mktmpdir } + + before do + stub_env('prometheus_multiproc_dir', metrics_dir) + end + + after do + FileUtils.rm_rf(metrics_dir, secure: true) + end + context 'starting the server' do context 'without --dryrun' do context 'when there are no sidekiq_health_checks settings set' do @@ -342,31 +353,33 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath end end - using RSpec::Parameterized::TableSyntax - - where(:sidekiq_exporter_enabled, :sidekiq_exporter_port, :sidekiq_health_checks_port, :start_metrics_server) do - true | '3807' | '3907' | true - true | '3807' | '3807' | false - false | '3807' | '3907' | false - false | '3807' | '3907' | false - end + context 'with valid settings' do + using RSpec::Parameterized::TableSyntax - with_them do - before do - allow(Gitlab::SidekiqCluster).to receive(:start) - allow(cli).to receive(:write_pid) - allow(cli).to receive(:trap_signals) - allow(cli).to receive(:start_loop) + where(:sidekiq_exporter_enabled, :sidekiq_exporter_port, :sidekiq_health_checks_port, :start_metrics_server) do + true | '3807' | '3907' | true + true | '3807' | '3807' | false + false | '3807' | '3907' | false + false | '3807' | '3907' | false end - specify do - if start_metrics_server - expect(MetricsServer).to receive(:spawn).with('sidekiq', wipe_metrics_dir: true) - else - expect(MetricsServer).not_to receive(:spawn) + with_them do + before do + allow(Gitlab::SidekiqCluster).to receive(:start) + allow(cli).to receive(:write_pid) + allow(cli).to receive(:trap_signals) + allow(cli).to receive(:start_loop) end - cli.run(%w(foo)) + specify do + if start_metrics_server + expect(MetricsServer).to receive(:spawn).with('sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: true, trapped_signals: trapped_signals) + else + expect(MetricsServer).not_to receive(:spawn) + end + + cli.run(%w(foo)) + end end end end @@ -388,7 +401,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath before do allow(cli).to receive(:sleep).with(a_kind_of(Numeric)) - allow(MetricsServer).to receive(:spawn).with('sidekiq', wipe_metrics_dir: false).and_return(99) + allow(MetricsServer).to receive(:spawn).and_return(99) cli.start_metrics_server end @@ -407,7 +420,9 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath allow(Gitlab::ProcessManagement).to receive(:all_alive?).with(an_instance_of(Array)).and_return(false) allow(cli).to receive(:stop_metrics_server) - expect(MetricsServer).to receive(:spawn).with('sidekiq', wipe_metrics_dir: false) + expect(MetricsServer).to receive(:spawn).with( + 'sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: false, trapped_signals: trapped_signals + ) cli.start_loop end @@ -484,9 +499,9 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do # rubocop:disable RSpec/FilePath end describe '#trap_signals' do - it 'traps the termination and forwarding signals' do - expect(Gitlab::ProcessManagement).to receive(:trap_terminate) - expect(Gitlab::ProcessManagement).to receive(:trap_forward) + it 'traps termination and sidekiq specific signals' do + expect(Gitlab::ProcessManagement).to receive(:trap_signals).with(%i[INT TERM]) + expect(Gitlab::ProcessManagement).to receive(:trap_signals).with(%i[TTIN USR1 USR2 HUP]) cli.trap_signals end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 94957020bcf..edb412cbb9c 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -26,9 +26,8 @@ RSpec.describe 'Database schema' do boards: %w[milestone_id iteration_id], chat_names: %w[chat_id team_id user_id], chat_teams: %w[team_id], - ci_builds: %w[erased_by_id runner_id trigger_request_id user_id], + ci_builds: %w[erased_by_id runner_id trigger_request_id], ci_namespace_monthly_usages: %w[namespace_id], - ci_pipelines: %w[user_id], ci_runner_projects: %w[runner_id], ci_trigger_requests: %w[commit_id], cluster_providers_aws: %w[security_group_id vpc_id access_key_id], diff --git a/spec/factories/packages/package_files.rb b/spec/factories/packages/package_files.rb index d9afbac1048..845fd882beb 100644 --- a/spec/factories/packages/package_files.rb +++ b/spec/factories/packages/package_files.rb @@ -323,6 +323,14 @@ FactoryBot.define do size { 1149.bytes } end + trait(:generic_zip) do + package + file_fixture { 'spec/fixtures/packages/generic/myfile.zip' } + file_name { "#{package.name}.zip" } + file_sha256 { '3559e770bd493b326e8ec5e6242f7206d3fbf94fa47c16f82d34a037daa113e5' } + size { 3989.bytes } + end + trait(:object_storage) do file_store { Packages::PackageFileUploader::Store::REMOTE } end diff --git a/spec/factories/packages/packages.rb b/spec/factories/packages/packages.rb index bb9aa95fe08..153518f4cd3 100644 --- a/spec/factories/packages/packages.rb +++ b/spec/factories/packages/packages.rb @@ -247,6 +247,12 @@ FactoryBot.define do sequence(:name) { |n| "generic-package-#{n}" } version { '1.0.0' } package_type { :generic } + + trait(:with_zip_file) do + after :create do |package| + create :package_file, :generic_zip, package: package + end + end end end end diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb index a380edff3a4..0397e72502a 100644 --- a/spec/features/global_search_spec.rb +++ b/spec/features/global_search_spec.rb @@ -57,6 +57,14 @@ RSpec.describe 'Global search' do expect(page).to have_selector('.search-form') expect(page).to have_no_selector('#js-header-search') end + + it 'focuses search input when shortcut "s" is pressed', :js do + expect(page).not_to have_selector('#search:focus') + + find('body').native.send_key('s') + + expect(page).to have_selector('#search:focus') + end end describe 'when new_header_search feature is enabled' do @@ -70,5 +78,13 @@ RSpec.describe 'Global search' do expect(page).to have_no_selector('.search-form') expect(page).to have_selector('#js-header-search') end + + it 'focuses search input when shortcut "s" is pressed', :js do + expect(page).not_to have_selector('#search:focus') + + find('body').native.send_key('s') + + expect(page).to have_selector('#search:focus') + end end end diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index 7d935298f38..24ba55994ae 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -63,11 +63,24 @@ RSpec.describe 'Profile account page', :js do end describe 'when I reset feed token' do - before do + it 'resets feed token with `hide_access_tokens` feature flag enabled' do visit profile_personal_access_tokens_path + + within('[data-testid="feed-token-container"]') do + previous_token = find_field('Feed token').value + + accept_confirm { click_link('reset this token') } + + click_button('Click to reveal') + + expect(find_field('Feed token').value).not_to eq(previous_token) + end end - it 'resets feed token' do + it 'resets feed token with `hide_access_tokens` feature flag disabled' do + stub_feature_flags(hide_access_tokens: false) + visit profile_personal_access_tokens_path + within('.feed-token-reset') do previous_token = find("#feed_token").value @@ -82,10 +95,26 @@ RSpec.describe 'Profile account page', :js do before do allow(Gitlab.config.incoming_email).to receive(:enabled).and_return(true) stub_feature_flags(bootstrap_confirmation_modals: false) + end + + it 'resets incoming email token with `hide_access_tokens` feature flag enabled' do visit profile_personal_access_tokens_path + + within('[data-testid="incoming-email-token-container"]') do + previous_token = find_field('Incoming email token').value + + accept_confirm { click_link('reset this token') } + + click_button('Click to reveal') + + expect(find_field('Incoming email token').value).not_to eq(previous_token) + end end - it 'resets incoming email token' do + it 'resets incoming email token with `hide_access_tokens` feature flag disabled' do + stub_feature_flags(hide_access_tokens: false) + visit profile_personal_access_tokens_path + within('.incoming-email-token-reset') do previous_token = find('#incoming_email_token').value diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 74505633cae..135a940807e 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -18,10 +18,6 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do find("#created-personal-access-token").value end - def feed_token - find("#feed_token").value - end - def feed_token_description "Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs." end @@ -136,12 +132,24 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do describe "feed token" do context "when enabled" do - it "displays feed token" do + it "displays feed token with `hide_access_tokens` feature flag enabled" do allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(false) visit profile_personal_access_tokens_path - expect(feed_token).to eq(user.feed_token) + within('[data-testid="feed-token-container"]') do + click_button('Click to reveal') + + expect(page).to have_field('Feed token', with: user.feed_token) + expect(page).to have_content(feed_token_description) + end + end + + it "displays feed token with `hide_access_tokens` feature flag disabled" do + stub_feature_flags(hide_access_tokens: false) + allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(false) + visit profile_personal_access_tokens_path + expect(page).to have_field('Feed token', with: user.feed_token) expect(page).to have_content(feed_token_description) end end @@ -151,8 +159,8 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do allow(Gitlab::CurrentSettings).to receive(:disable_feed_token).and_return(true) visit profile_personal_access_tokens_path - expect(page).to have_no_content(feed_token_description) - expect(page).to have_no_css("#feed_token") + expect(page).not_to have_content(feed_token_description) + expect(page).not_to have_field('Feed token') end end end diff --git a/spec/fixtures/packages/generic/myfile.zip b/spec/fixtures/packages/generic/myfile.zip Binary files differnew file mode 100644 index 00000000000..6048bd2f246 --- /dev/null +++ b/spec/fixtures/packages/generic/myfile.zip diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index a64135601ae..014a7854024 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -1,8 +1,7 @@ -import MockAdapter from 'axios-mock-adapter'; import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; -import axios from '~/lib/utils/axios_utils'; +import { CACHE_VERSION_KEY, CACHE_KEY } from '~/emoji/constants'; -export const emojiFixtureMap = { +export const validEmoji = { atom: { moji: '⚛', description: 'atom symbol', @@ -49,11 +48,39 @@ export const emojiFixtureMap = { unicodeVersion: '5.1', description: 'white medium star', }, + gay_pride_flag: { + moji: '🏳️🌈', + unicodeVersion: '7.0', + description: 'because it contains a zero width joiner', + }, + family_mmb: { + moji: '👨👨👦', + unicodeVersion: '6.0', + description: 'because it contains multiple zero width joiners', + }, +}; + +export const invalidEmoji = { xss: { moji: '<img src=x onerror=prompt(1)>', unicodeVersion: '5.1', description: 'xss', }, + non_moji: { + moji: 'I am not an emoji...', + unicodeVersion: '9.0', + description: '...and should be filtered out', + }, + multiple_moji: { + moji: '🍂🏭', + unicodeVersion: '9.0', + description: 'Multiple separate emoji that are not joined by a zero width joiner', + }, +}; + +export const emojiFixtureMap = { + ...validEmoji, + ...invalidEmoji, }; export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => { @@ -63,11 +90,14 @@ export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => { return acc; }, {}); -export async function initEmojiMock(mockData = mockEmojiData) { - const mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(mockData)); +export function clearEmojiMock() { + localStorage.clear(); + initEmojiMap.promise = null; +} +export async function initEmojiMock(mockData = mockEmojiData) { + clearEmojiMock(); + localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); + localStorage.setItem(CACHE_KEY, JSON.stringify(mockData)); await initEmojiMap(); - - return mock; } diff --git a/spec/frontend/access_tokens/components/token_spec.js b/spec/frontend/access_tokens/components/token_spec.js new file mode 100644 index 00000000000..1af21aaa8cd --- /dev/null +++ b/spec/frontend/access_tokens/components/token_spec.js @@ -0,0 +1,65 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +import Token from '~/access_tokens/components/token.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +describe('Token', () => { + let wrapper; + + const defaultPropsData = { + token: 'az4a2l5f8ssa0zvdfbhidbzlx', + inputId: 'feed_token', + inputLabel: 'Feed token', + copyButtonTitle: 'Copy feed token', + }; + + const defaultSlots = { + title: 'Feed token title', + description: 'Feed token description', + 'input-description': 'Feed token input description', + }; + + const createComponent = () => { + wrapper = mountExtended(Token, { propsData: defaultPropsData, slots: defaultSlots }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders title slot', () => { + createComponent(); + + expect(wrapper.findByText(defaultSlots.title, { selector: 'h4' }).exists()).toBe(true); + }); + + it('renders description slot', () => { + createComponent(); + + expect(wrapper.findByText(defaultSlots.description).exists()).toBe(true); + }); + + it('renders input description slot', () => { + createComponent(); + + expect(wrapper.findByText(defaultSlots['input-description']).exists()).toBe(true); + }); + + it('correctly passes props to `InputCopyToggleVisibility` component', () => { + createComponent(); + + const inputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility); + + expect(inputCopyToggleVisibilityComponent.props()).toMatchObject({ + formInputGroupProps: { + id: defaultPropsData.inputId, + }, + value: defaultPropsData.token, + copyButtonTitle: defaultPropsData.copyButtonTitle, + }); + expect(inputCopyToggleVisibilityComponent.attributes()).toMatchObject({ + label: defaultPropsData.inputLabel, + 'label-for': defaultPropsData.inputId, + }); + }); +}); diff --git a/spec/frontend/access_tokens/components/tokens_app_spec.js b/spec/frontend/access_tokens/components/tokens_app_spec.js new file mode 100644 index 00000000000..d7acfbb47eb --- /dev/null +++ b/spec/frontend/access_tokens/components/tokens_app_spec.js @@ -0,0 +1,148 @@ +import { merge } from 'lodash'; + +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; + +import TokensApp from '~/access_tokens/components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants'; + +describe('TokensApp', () => { + let wrapper; + + const defaultProvide = { + tokenTypes: { + [FEED_TOKEN]: { + enabled: true, + token: 'DUKu345VD73Py7zz3z89', + resetPath: '/-/profile/reset_feed_token', + }, + [INCOMING_EMAIL_TOKEN]: { + enabled: true, + token: 'az4a2l5f8ssa0zvdfbhidbzlx', + resetPath: '/-/profile/reset_incoming_email_token', + }, + [STATIC_OBJECT_TOKEN]: { + enabled: true, + token: 'QHXwGHYioHTgxQnAcyZ-', + resetPath: '/-/profile/reset_static_object_token', + }, + }, + }; + + const createComponent = (options = {}) => { + wrapper = mountExtended(TokensApp, merge({}, { provide: defaultProvide }, options)); + }; + + const expectTokenRendered = ({ + testId, + expectedLabel, + expectedDescription, + expectedInputDescription, + expectedResetPath, + expectedResetConfirmMessage, + expectedProps, + }) => { + const container = extendedWrapper(wrapper.findByTestId(testId)); + + expect(container.findByText(expectedLabel, { selector: 'h4' }).exists()).toBe(true); + expect(container.findByText(expectedDescription).exists()).toBe(true); + expect(container.findByText(expectedInputDescription, { exact: false }).exists()).toBe(true); + expect(container.findByText('reset this token').attributes()).toMatchObject({ + 'data-confirm': expectedResetConfirmMessage, + 'data-method': 'put', + href: expectedResetPath, + }); + expect(container.props()).toMatchObject(expectedProps); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all enabled tokens', () => { + createComponent(); + + expectTokenRendered({ + testId: TokensApp.htmlAttributes[FEED_TOKEN].containerTestId, + expectedLabel: TokensApp.i18n[FEED_TOKEN].label, + expectedDescription: TokensApp.i18n[FEED_TOKEN].description, + expectedInputDescription: + 'Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you.', + expectedResetPath: defaultProvide.tokenTypes[FEED_TOKEN].resetPath, + expectedResetConfirmMessage: TokensApp.i18n[FEED_TOKEN].resetConfirmMessage, + expectedProps: { + token: defaultProvide.tokenTypes[FEED_TOKEN].token, + inputId: TokensApp.htmlAttributes[FEED_TOKEN].inputId, + inputLabel: TokensApp.i18n[FEED_TOKEN].label, + copyButtonTitle: TokensApp.i18n[FEED_TOKEN].copyButtonTitle, + }, + }); + + expectTokenRendered({ + testId: TokensApp.htmlAttributes[INCOMING_EMAIL_TOKEN].containerTestId, + expectedLabel: TokensApp.i18n[INCOMING_EMAIL_TOKEN].label, + expectedDescription: TokensApp.i18n[INCOMING_EMAIL_TOKEN].description, + expectedInputDescription: + 'Keep this token secret. Anyone who has it can create issues as if they were you.', + expectedResetPath: defaultProvide.tokenTypes[INCOMING_EMAIL_TOKEN].resetPath, + expectedResetConfirmMessage: TokensApp.i18n[INCOMING_EMAIL_TOKEN].resetConfirmMessage, + expectedProps: { + token: defaultProvide.tokenTypes[INCOMING_EMAIL_TOKEN].token, + inputId: TokensApp.htmlAttributes[INCOMING_EMAIL_TOKEN].inputId, + inputLabel: TokensApp.i18n[INCOMING_EMAIL_TOKEN].label, + copyButtonTitle: TokensApp.i18n[INCOMING_EMAIL_TOKEN].copyButtonTitle, + }, + }); + + expectTokenRendered({ + testId: TokensApp.htmlAttributes[STATIC_OBJECT_TOKEN].containerTestId, + expectedLabel: TokensApp.i18n[STATIC_OBJECT_TOKEN].label, + expectedDescription: TokensApp.i18n[STATIC_OBJECT_TOKEN].description, + expectedInputDescription: + 'Keep this token secret. Anyone who has it can access repository static objects as if they were you.', + expectedResetPath: defaultProvide.tokenTypes[STATIC_OBJECT_TOKEN].resetPath, + expectedResetConfirmMessage: TokensApp.i18n[STATIC_OBJECT_TOKEN].resetConfirmMessage, + expectedProps: { + token: defaultProvide.tokenTypes[STATIC_OBJECT_TOKEN].token, + inputId: TokensApp.htmlAttributes[STATIC_OBJECT_TOKEN].inputId, + inputLabel: TokensApp.i18n[STATIC_OBJECT_TOKEN].label, + copyButtonTitle: TokensApp.i18n[STATIC_OBJECT_TOKEN].copyButtonTitle, + }, + }); + }); + + it("doesn't render disabled tokens", () => { + createComponent({ + provide: { + tokenTypes: { + [FEED_TOKEN]: { + enabled: false, + }, + }, + }, + }); + + expect( + wrapper.findByTestId(TokensApp.htmlAttributes[FEED_TOKEN].containerTestId).exists(), + ).toBe(false); + }); + + describe('when there are tokens missing an `i18n` definition', () => { + it('renders without errors', () => { + createComponent({ + provide: { + tokenTypes: { + fooBar: { + enabled: true, + token: 'rewjoa58dfm54jfkdlsdf', + resetPath: '/-/profile/foo_bar', + }, + }, + }, + }); + + expect( + wrapper.findByTestId(TokensApp.htmlAttributes[FEED_TOKEN].containerTestId).exists(), + ).toBe(true); + }); + }); +}); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index f4d3fd97fd8..ec5b6a5597b 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -12,6 +12,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub checked="true" @@ -28,6 +29,7 @@ exports[`Alert integration settings form default state should match the default label-for="alert-integration-settings-issue-template" label-size="sm" labeldescription="" + optionaltext="(optional)" > <label class="gl-display-inline-flex" @@ -83,6 +85,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0 gl-mb-5" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub> <span> @@ -94,6 +97,7 @@ exports[`Alert integration settings form default state should match the default <gl-form-group-stub class="gl-pl-0 gl-mb-5" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub checked="true" diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 09270174674..c4002ec11f3 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -1,15 +1,12 @@ -import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import Cookies from 'js-cookie'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import loadAwardsHandler from '~/awards_handler'; -import { EMOJI_VERSION } from '~/emoji'; -import axios from '~/lib/utils/axios_utils'; window.gl = window.gl || {}; window.gon = window.gon || {}; -let mock; let awardsHandler = null; const urlRoot = gon.relative_url_root; @@ -76,8 +73,7 @@ describe('AwardsHandler', () => { }; beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + await initEmojiMock(emojiData); loadFixtures('snippets/show.html'); @@ -89,7 +85,7 @@ describe('AwardsHandler', () => { // restore original url root value gon.relative_url_root = urlRoot; - mock.restore(); + clearEmojiMock(); // Undo what we did to the shared <body> $('body').removeAttr('data-page'); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index d23a0a84997..0f4e2e08dbd 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -1,15 +1,13 @@ -import MockAdapter from 'axios-mock-adapter'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import waitForPromises from 'helpers/wait_for_promises'; import installGlEmojiElement from '~/behaviors/gl_emoji'; -import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; +import { EMOJI_VERSION } from '~/emoji'; import * as EmojiUnicodeSupport from '~/emoji/support'; -import axios from '~/lib/utils/axios_utils'; jest.mock('~/emoji/support'); describe('gl_emoji', () => { - let mock; const emojiData = { grey_question: { c: 'symbols', @@ -38,15 +36,12 @@ describe('gl_emoji', () => { return div.firstElementChild; } - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); - - return initEmojiMap().catch(() => {}); + beforeEach(async () => { + await initEmojiMock(emojiData); }); afterEach(() => { - mock.restore(); + clearEmojiMock(); document.body.innerHTML = ''; }); diff --git a/spec/frontend/crm/new_contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js index 1497c348676..b2753ad8cf5 100644 --- a/spec/frontend/crm/new_contact_form_spec.js +++ b/spec/frontend/crm/contact_form_spec.js @@ -4,41 +4,49 @@ import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import NewContactForm from '~/crm/components/new_contact_form.vue'; +import ContactForm from '~/crm/components/contact_form.vue'; import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql'; +import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql'; import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; import { createContactMutationErrorResponse, createContactMutationResponse, getGroupContactsQueryResponse, + updateContactMutationErrorResponse, + updateContactMutationResponse, } from './mock_data'; -describe('Customer relations contacts root app', () => { +describe('Customer relations contact form component', () => { Vue.use(VueApollo); let wrapper; let fakeApollo; + let mutation; let queryHandler; - const findCreateNewContactButton = () => wrapper.findByTestId('create-new-contact-button'); + const findSaveContactButton = () => wrapper.findByTestId('save-contact-button'); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findForm = () => wrapper.find('form'); const findError = () => wrapper.findComponent(GlAlert); - const mountComponent = ({ mountFunction = shallowMountExtended } = {}) => { - fakeApollo = createMockApollo([[createContactMutation, queryHandler]]); + const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => { + fakeApollo = createMockApollo([[mutation, queryHandler]]); fakeApollo.clients.defaultClient.cache.writeQuery({ query: getGroupContactsQuery, variables: { groupFullPath: 'flightjs' }, data: getGroupContactsQueryResponse.data, }); - wrapper = mountFunction(NewContactForm, { + const propsData = { drawerOpen: true }; + if (editForm) + propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' }; + wrapper = mountFunction(ContactForm, { provide: { groupId: 26, groupFullPath: 'flightjs' }, apolloProvider: fakeApollo, - propsData: { drawerOpen: true }, + propsData, }); }; beforeEach(() => { + mutation = createContactMutation; queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse); }); @@ -47,14 +55,14 @@ describe('Customer relations contacts root app', () => { fakeApollo = null; }); - describe('Create new contact button', () => { - it('should be disabled by default', () => { + describe('Save contact button', () => { + it('should be disabled when required fields are empty', () => { mountComponent(); - expect(findCreateNewContactButton().attributes('disabled')).toBeTruthy(); + expect(findSaveContactButton().props('disabled')).toBe(true); }); - it('should not be disabled when first, last and email have values', async () => { + it('should not be disabled when required fields have values', async () => { mountComponent(); wrapper.find('#contact-first-name').vm.$emit('input', 'A'); @@ -62,7 +70,7 @@ describe('Customer relations contacts root app', () => { wrapper.find('#contact-email').vm.$emit('input', 'C'); await waitForPromises(); - expect(findCreateNewContactButton().attributes('disabled')).toBeFalsy(); + expect(findSaveContactButton().props('disabled')).toBe(false); }); }); @@ -74,7 +82,7 @@ describe('Customer relations contacts root app', () => { expect(wrapper.emitted().close).toBeTruthy(); }); - describe('when query is successful', () => { + describe('when create mutation is successful', () => { it("should emit 'close'", async () => { mountComponent(); @@ -85,7 +93,7 @@ describe('Customer relations contacts root app', () => { }); }); - describe('when query fails', () => { + describe('when create mutation fails', () => { it('should show error on reject', async () => { queryHandler = jest.fn().mockRejectedValue('ERROR'); mountComponent(); @@ -107,4 +115,43 @@ describe('Customer relations contacts root app', () => { expect(findError().text()).toBe('Phone is invalid.'); }); }); + + describe('when update mutation is successful', () => { + it("should emit 'close'", async () => { + mutation = updateContactMutation; + queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse); + mountComponent({ editForm: true }); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(wrapper.emitted().close).toBeTruthy(); + }); + }); + + describe('when update mutation fails', () => { + beforeEach(() => { + mutation = updateContactMutation; + }); + + it('should show error on reject', async () => { + queryHandler = jest.fn().mockRejectedValue('ERROR'); + mountComponent({ editForm: true }); + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + }); + + it('should show error on error response', async () => { + queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse); + mountComponent({ editForm: true }); + + findForm().trigger('submit'); + await waitForPromises(); + + expect(findError().exists()).toBe(true); + expect(findError().text()).toBe('Email is invalid.'); + }); + }); }); diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js index 18bd2d7c45b..b30349305a3 100644 --- a/spec/frontend/crm/contacts_root_spec.js +++ b/spec/frontend/crm/contacts_root_spec.js @@ -6,8 +6,10 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ContactsRoot from '~/crm/components/contacts_root.vue'; -import NewContactForm from '~/crm/components/new_contact_form.vue'; +import ContactForm from '~/crm/components/contact_form.vue'; import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql'; +import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants'; +import routes from '~/crm/routes'; import { getGroupContactsQueryResponse } from './mock_data'; describe('Customer relations contacts root app', () => { @@ -21,7 +23,8 @@ describe('Customer relations contacts root app', () => { const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName }); const findIssuesLinks = () => wrapper.findAllByTestId('issues-link'); const findNewContactButton = () => wrapper.findByTestId('new-contact-button'); - const findNewContactForm = () => wrapper.findComponent(NewContactForm); + const findEditContactButton = () => wrapper.findByTestId('edit-contact-button'); + const findContactForm = () => wrapper.findComponent(ContactForm); const findError = () => wrapper.findComponent(GlAlert); const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse); @@ -49,7 +52,7 @@ describe('Customer relations contacts root app', () => { router = new VueRouter({ base: basePath, mode: 'history', - routes: [], + routes, }); }); @@ -79,12 +82,12 @@ describe('Customer relations contacts root app', () => { }); }); - describe('new contact form', () => { + describe('contact form', () => { it('should not exist by default', async () => { mountComponent(); await waitForPromises(); - expect(findNewContactForm().exists()).toBe(false); + expect(findContactForm().exists()).toBe(false); }); it('should exist when user clicks new contact button', async () => { @@ -93,25 +96,54 @@ describe('Customer relations contacts root app', () => { findNewContactButton().vm.$emit('click'); await waitForPromises(); - expect(findNewContactForm().exists()).toBe(true); + expect(findContactForm().exists()).toBe(true); }); - it('should exist when user navigates directly to /new', async () => { - router.replace({ path: '/new' }); + it('should exist when user navigates directly to `new` route', async () => { + router.replace({ name: NEW_ROUTE_NAME }); mountComponent(); await waitForPromises(); - expect(findNewContactForm().exists()).toBe(true); + expect(findContactForm().exists()).toBe(true); }); - it('should not exist when form emits close', async () => { - router.replace({ path: '/new' }); + it('should exist when user clicks edit contact button', async () => { + mountComponent({ mountFunction: mountExtended }); + await waitForPromises(); + + findEditContactButton().vm.$emit('click'); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(true); + }); + + it('should exist when user navigates directly to `edit` route', async () => { + router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); mountComponent(); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(true); + }); + + it('should not exist when new form emits close', async () => { + router.replace({ name: NEW_ROUTE_NAME }); + mountComponent(); + + findContactForm().vm.$emit('close'); + await waitForPromises(); + + expect(findContactForm().exists()).toBe(false); + }); + + it('should not exist when edit form emits close', async () => { + router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } }); + mountComponent(); + await waitForPromises(); - findNewContactForm().vm.$emit('close'); + findContactForm().vm.$emit('close'); await waitForPromises(); - expect(findNewContactForm().exists()).toBe(false); + expect(findContactForm().exists()).toBe(false); }); }); diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js index e784ac3764d..3abbc488081 100644 --- a/spec/frontend/crm/mock_data.js +++ b/spec/frontend/crm/mock_data.js @@ -106,3 +106,31 @@ export const createContactMutationErrorResponse = { }, }, }; + +export const updateContactMutationResponse = { + data: { + customerRelationsContactUpdate: { + __typeName: 'CustomerRelationsContactCreatePayload', + contact: { + __typename: 'CustomerRelationsContact', + id: 'gid://gitlab/CustomerRelations::Contact/1', + firstName: 'First', + lastName: 'Last', + email: 'email@example.com', + phone: null, + description: null, + organization: null, + }, + errors: [], + }, + }, +}; + +export const updateContactMutationErrorResponse = { + data: { + customerRelationsContactUpdate: { + contact: null, + errors: ['Email is invalid.'], + }, + }, +}; diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 9652c513671..cc037586496 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -1,6 +1,21 @@ -import { emojiFixtureMap, mockEmojiData, initEmojiMock } from 'helpers/emoji'; +import { + emojiFixtureMap, + mockEmojiData, + initEmojiMock, + validEmoji, + invalidEmoji, + clearEmojiMock, +} from 'helpers/emoji'; import { trimText } from 'helpers/text_helper'; -import { glEmojiTag, searchEmoji, getEmojiInfo, sortEmoji } from '~/emoji'; +import { + glEmojiTag, + searchEmoji, + getEmojiInfo, + sortEmoji, + initEmojiMap, + getAllEmoji, +} from '~/emoji'; + import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -9,7 +24,6 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; -import { sanitize } from '~/lib/dompurify'; const emptySupportMap = { personZwj: false, @@ -31,14 +45,55 @@ const emptySupportMap = { }; describe('emoji', () => { - let mock; - beforeEach(async () => { - mock = await initEmojiMock(); + await initEmojiMock(); }); afterEach(() => { - mock.restore(); + clearEmojiMock(); + }); + + describe('initEmojiMap', () => { + it('should contain valid emoji', async () => { + await initEmojiMap(); + + const allEmoji = Object.keys(getAllEmoji()); + Object.keys(validEmoji).forEach((key) => { + expect(allEmoji.includes(key)).toBe(true); + }); + }); + + it('should not contain invalid emoji', async () => { + await initEmojiMap(); + + const allEmoji = Object.keys(getAllEmoji()); + Object.keys(invalidEmoji).forEach((key) => { + expect(allEmoji.includes(key)).toBe(false); + }); + }); + + it('fixes broken pride emoji', async () => { + clearEmojiMock(); + await initEmojiMock({ + gay_pride_flag: { + c: 'flags', + // Without a zero-width joiner + e: '🏳🌈', + name: 'gay_pride_flag', + u: '6.0', + }, + }); + + expect(getAllEmoji()).toEqual({ + gay_pride_flag: { + c: 'flags', + // With a zero-width joiner + e: '🏳️🌈', + name: 'gay_pride_flag', + u: '6.0', + }, + }); + }); }); describe('glEmojiTag', () => { @@ -378,32 +433,14 @@ describe('emoji', () => { }); describe('searchEmoji', () => { - const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => { - const { name, e, u, d } = mockEmojiData[k]; - acc[k] = { name, e: sanitize(e), u, d }; - - return acc; - }, {}); - it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => { const search = searchEmoji(input); - const expected = [ - 'atom', - 'bomb', - 'construction_worker_tone5', - 'five', - 'grey_question', - 'black_heart', - 'heart', - 'custard', - 'star', - 'xss', - ].map((name) => { + const expected = Object.keys(validEmoji).map((name) => { return { - emoji: emojiFixture[name], + emoji: mockEmojiData[name], field: 'd', - fieldValue: emojiFixture[name].d, + fieldValue: mockEmojiData[name].d, score: 0, }; }); @@ -453,7 +490,7 @@ describe('emoji', () => { const { field, score, fieldValue, name } = item; return { - emoji: emojiFixture[name], + emoji: mockEmojiData[name], field, fieldValue, score, @@ -564,9 +601,9 @@ describe('emoji', () => { const { field, score, name } = item; return { - emoji: emojiFixture[name], + emoji: mockEmojiData[name], field, - fieldValue: emojiFixture[name][field], + fieldValue: mockEmojiData[name][field], score, }; }); @@ -622,13 +659,4 @@ describe('emoji', () => { expect(sortEmoji(scoredItems)).toEqual(expected); }); }); - - describe('sanitize emojis', () => { - it('should return sanitized emoji', () => { - expect(getEmojiInfo('xss')).toEqual({ - ...mockEmojiData.xss, - e: '<img src="x">', - }); - }); - }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 631e3307f7f..1ab3286fe4c 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import GfmAutoComplete, { membersBeforeSave, highlighter } from 'ee_else_ce/gfm_auto_complete'; -import { initEmojiMock } from 'helpers/emoji'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -803,8 +803,6 @@ describe('GfmAutoComplete', () => { }); describe('emoji', () => { - let mock; - const mockItem = { 'atwho-at': ':', emoji: { @@ -818,14 +816,14 @@ describe('GfmAutoComplete', () => { }; beforeEach(async () => { - mock = await initEmojiMock(); + await initEmojiMock(); await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':'); if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded'); }); afterEach(() => { - mock.restore(); + clearEmojiMock(); }); describe('Emoji.templateFunction', () => { diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 33e2c0db5e5..9447e7daba8 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -47,6 +47,7 @@ exports[`grafana integration component default state to match the default snapsh label="Enable authentication" label-for="grafana-integration-enabled" labeldescription="" + optionaltext="(optional)" > <gl-form-checkbox-stub id="grafana-integration-enabled" @@ -62,6 +63,7 @@ exports[`grafana integration component default state to match the default snapsh label="Grafana URL" label-for="grafana-url" labeldescription="" + optionaltext="(optional)" > <gl-form-input-stub id="grafana-url" @@ -74,6 +76,7 @@ exports[`grafana integration component default state to match the default snapsh label="API token" label-for="grafana-token" labeldescription="" + optionaltext="(optional)" > <gl-form-input-stub id="grafana-token" diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 174a42c5dc2..feee14c9c40 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -14,6 +14,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] = <gl-form-group-stub class="col-8 col-md-9 gl-p-0" labeldescription="" + optionaltext="(optional)" > <gl-toggle-stub id="active" @@ -28,6 +29,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] = label="Webhook URL" label-for="url" labeldescription="" + optionaltext="(optional)" > <gl-form-input-group-stub data-testid="webhook-url" diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index 1a874c3dcd6..c968c28c811 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -52,6 +52,7 @@ exports[`self monitor component When the self monitor project has not been creat <gl-form-group-stub labeldescription="" + optionaltext="(optional)" > <gl-toggle-stub label="Self monitoring" diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index 7577c65132b..d7261784edc 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -1,6 +1,6 @@ import { GlModal, GlFormCheckbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { initEmojiMock } from 'helpers/emoji'; +import { initEmojiMock, clearEmojiMock } from 'helpers/emoji'; import * as UserApi from '~/api/user_api'; import EmojiPicker from '~/emoji/components/picker.vue'; import createFlash from '~/flash'; @@ -12,7 +12,6 @@ jest.mock('~/flash'); describe('SetStatusModalWrapper', () => { let wrapper; - let mockEmoji; const $toast = { show: jest.fn(), }; @@ -63,12 +62,12 @@ describe('SetStatusModalWrapper', () => { afterEach(() => { wrapper.destroy(); - mockEmoji.restore(); + clearEmojiMock(); }); describe('with minimum props', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent(); return initModal(); }); @@ -112,7 +111,7 @@ describe('SetStatusModalWrapper', () => { describe('improvedEmojiPicker is true', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({}, true); return initModal(); }); @@ -126,7 +125,7 @@ describe('SetStatusModalWrapper', () => { describe('with no currentMessage set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentMessage: '' }); return initModal(); }); @@ -146,7 +145,7 @@ describe('SetStatusModalWrapper', () => { describe('with no currentEmoji set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '' }); return initModal(); }); @@ -161,7 +160,7 @@ describe('SetStatusModalWrapper', () => { describe('with no currentMessage set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); return initModal(); }); @@ -174,7 +173,7 @@ describe('SetStatusModalWrapper', () => { describe('with currentClearStatusAfter set', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' }); return initModal(); }); @@ -190,7 +189,7 @@ describe('SetStatusModalWrapper', () => { describe('update status', () => { describe('succeeds', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent(); await initModal(); @@ -246,7 +245,7 @@ describe('SetStatusModalWrapper', () => { describe('success message', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); jest.spyOn(UserApi, 'updateUserStatus').mockResolvedValue(); return initModal({ mockOnUpdateSuccess: false }); @@ -262,7 +261,7 @@ describe('SetStatusModalWrapper', () => { describe('with errors', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent(); await initModal(); @@ -279,7 +278,7 @@ describe('SetStatusModalWrapper', () => { describe('error message', () => { beforeEach(async () => { - mockEmoji = await initEmojiMock(); + await initEmojiMock(); wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue(); return initModal({ mockOnUpdateFailure: false }); diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 455db325066..49148123a1c 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -25,6 +25,7 @@ describe('Shortcuts', () => { jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus'); jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus'); + jest.spyOn(document.querySelector('#search'), 'focus'); new Shortcuts(); // eslint-disable-line no-new }); @@ -111,4 +112,12 @@ describe('Shortcuts', () => { }); }); }); + + describe('focusSearch', () => { + it('focuses the search bar', () => { + Shortcuts.focusSearch(createEvent('KeyboardEvent')); + + expect(document.querySelector('#search').focus).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 5df69ffb5f8..f4ebc5c3e3f 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -23,6 +23,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = class="gl-mb-0" id="visibility-level-setting" labeldescription="" + optionaltext="(optional)" > <gl-form-radio-group-stub checked="private" diff --git a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap index ff1dad2de68..58ad1f681bc 100644 --- a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap +++ b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap @@ -5,6 +5,7 @@ exports[`Title edit field matches the snapshot 1`] = ` label="Title" label-for="title-field-edit" labeldescription="" + optionaltext="(optional)" > <gl-form-input-stub /> </gl-form-group-stub> diff --git a/spec/helpers/access_tokens_helper_spec.rb b/spec/helpers/access_tokens_helper_spec.rb index 28041203447..c2c918bc6b0 100644 --- a/spec/helpers/access_tokens_helper_spec.rb +++ b/spec/helpers/access_tokens_helper_spec.rb @@ -15,4 +15,53 @@ RSpec.describe AccessTokensHelper do it { expect(helper.scope_description(prefix)).to eq(description_location) } end end + + describe '#tokens_app_data' do + let_it_be(:feed_token) { 'DUKu345VD73Py7zz3z89' } + let_it_be(:incoming_email_token) { 'az4a2l5f8ssa0zvdfbhidbzlx' } + let_it_be(:static_object_token) { 'QHXwGHYioHTgxQnAcyZ-' } + let_it_be(:feed_token_reset_path) { '/-/profile/reset_feed_token' } + let_it_be(:incoming_email_token_reset_path) { '/-/profile/reset_incoming_email_token' } + let_it_be(:static_object_token_reset_path) { '/-/profile/reset_static_object_token' } + let_it_be(:user) do + build( + :user, + feed_token: feed_token, + incoming_email_token: incoming_email_token, + static_object_token: static_object_token + ) + end + + it 'returns expected json' do + allow(Gitlab::CurrentSettings).to receive_messages( + disable_feed_token: false, + static_objects_external_storage_enabled?: true + ) + allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(true) + allow(helper).to receive_messages( + current_user: user, + reset_feed_token_profile_path: feed_token_reset_path, + reset_incoming_email_token_profile_path: incoming_email_token_reset_path, + reset_static_object_token_profile_path: static_object_token_reset_path + ) + + expect(helper.tokens_app_data).to eq({ + feed_token: { + enabled: true, + token: feed_token, + reset_path: feed_token_reset_path + }, + incoming_email_token: { + enabled: true, + token: incoming_email_token, + reset_path: incoming_email_token_reset_path + }, + static_object_token: { + enabled: true, + token: static_object_token, + reset_path: static_object_token_reset_path + } + }.to_json) + end + end end diff --git a/spec/lib/gitlab/process_management_spec.rb b/spec/lib/gitlab/process_management_spec.rb index 4e367e258f2..a71a476b540 100644 --- a/spec/lib/gitlab/process_management_spec.rb +++ b/spec/lib/gitlab/process_management_spec.rb @@ -12,21 +12,12 @@ RSpec.describe Gitlab::ProcessManagement do end end - describe '.trap_terminate' do - it 'traps the termination signals' do - expect(described_class).to receive(:trap_signals) - .with(described_class::TERMINATE_SIGNALS) + describe '.modify_signals' do + it 'traps the given signals with the given command' do + expect(described_class).to receive(:trap).ordered.with(:INT, 'DEFAULT') + expect(described_class).to receive(:trap).ordered.with(:HUP, 'DEFAULT') - described_class.trap_terminate { } - end - end - - describe '.trap_forward' do - it 'traps the signals to forward' do - expect(described_class).to receive(:trap_signals) - .with(described_class::FORWARD_SIGNALS) - - described_class.trap_forward { } + described_class.modify_signals(%i(INT HUP), 'DEFAULT') end end diff --git a/spec/metrics_server/metrics_server_spec.rb b/spec/metrics_server/metrics_server_spec.rb index fb910bbad78..4e3c6900875 100644 --- a/spec/metrics_server/metrics_server_spec.rb +++ b/spec/metrics_server/metrics_server_spec.rb @@ -8,52 +8,67 @@ require_relative '../support/helpers/next_instance_of' RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath include NextInstanceOf + before do + # We do not want this to have knock-on effects on the test process. + allow(Gitlab::ProcessManagement).to receive(:modify_signals) + end + describe '.spawn' do - let(:env) do - { - 'METRICS_SERVER_TARGET' => 'sidekiq', - 'GITLAB_CONFIG' => nil, - 'WIPE_METRICS_DIR' => 'false' - } + context 'when in parent process' do + it 'forks into a new process and detaches it' do + expect(Process).to receive(:fork).and_return(99) + expect(Process).to receive(:detach).with(99) + + described_class.spawn('sidekiq', metrics_dir: 'path/to/metrics') + end end - it 'spawns a process with the correct environment variables and detaches it' do - expect(Process).to receive(:spawn).with(env, anything, err: $stderr, out: $stdout).and_return(99) - expect(Process).to receive(:detach).with(99) + context 'when in child process' do + before do + # This signals the process that it's "inside" the fork + expect(Process).to receive(:fork).and_return(nil) + expect(Process).not_to receive(:detach) + end + + it 'starts the metrics server with the given arguments' do + expect_next_instance_of(MetricsServer) do |server| + expect(server).to receive(:start) + end + + described_class.spawn('sidekiq', metrics_dir: 'path/to/metrics') + end + + it 'resets signal handlers from parent process' do + expect(Gitlab::ProcessManagement).to receive(:modify_signals).with(%i[A B], 'DEFAULT') - described_class.spawn('sidekiq') + described_class.spawn('sidekiq', metrics_dir: 'path/to/metrics', trapped_signals: %i[A B]) + end end end describe '#start' do let(:exporter_class) { Class.new(Gitlab::Metrics::Exporter::BaseExporter) } let(:exporter_double) { double('fake_exporter', start: true) } - let(:prometheus_client_double) { double(::Prometheus::Client) } - let(:prometheus_config) { ::Prometheus::Client::Configuration.new } + let(:prometheus_config) { ::Prometheus::Client.configuration } let(:metrics_dir) { Dir.mktmpdir } - let(:settings_double) { double(:settings, sidekiq_exporter: {}) } - let!(:old_metrics_dir) { ::Prometheus::Client.configuration.multiprocess_files_dir } + let(:settings) { { "fake_exporter" => { "enabled" => true } } } + let!(:old_metrics_dir) { prometheus_config.multiprocess_files_dir } subject(:metrics_server) { described_class.new('fake', metrics_dir, true)} before do - stub_env('prometheus_multiproc_dir', metrics_dir) stub_const('Gitlab::Metrics::Exporter::FakeExporter', exporter_class) - allow(exporter_class).to receive(:instance).with({}, synchronous: true).and_return(exporter_double) - allow(Settings).to receive(:monitoring).and_return(settings_double) + expect(exporter_class).to receive(:instance).with(settings['fake_exporter'], synchronous: true).and_return(exporter_double) + expect(Settings).to receive(:monitoring).and_return(settings) end after do Gitlab::Metrics.reset_registry! - - ::Prometheus::CleanupMultiprocDirService.new.execute - Dir.rmdir(metrics_dir) - ::Prometheus::Client.configuration.multiprocess_files_dir = old_metrics_dir + FileUtils.rm_rf(metrics_dir, secure: true) + prometheus_config.multiprocess_files_dir = old_metrics_dir end it 'configures ::Prometheus::Client' do - allow(prometheus_client_double).to receive(:configuration).and_return(prometheus_config) - metrics_server.start expect(prometheus_config.multiprocess_files_dir).to eq metrics_dir @@ -90,12 +105,5 @@ RSpec.describe MetricsServer do # rubocop:disable RSpec/FilePath metrics_server.start end - - it 'sends the correct Settings to the exporter instance' do - expect(Settings).to receive(:monitoring).and_return(settings_double) - expect(settings_double).to receive(:sidekiq_exporter) - - metrics_server.start - end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index ea626a1da6e..b9a12339e61 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -5421,4 +5421,9 @@ RSpec.describe Ci::Build do it_behaves_like 'it has loose foreign keys' do let(:factory_name) { :ci_build } end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) { create(:ci_build, user: create(:user)) } + let!(:parent) { model.user } + end end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index d63f87e8943..38061e0975f 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -700,4 +700,8 @@ RSpec.describe Ci::JobArtifact do when changes or new entries are made. MSG end + + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :ci_job_artifact } + end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 07e68236d45..fd9970699d7 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -4672,4 +4672,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it_behaves_like 'it has loose foreign keys' do let(:factory_name) { :ci_pipeline } end + + it_behaves_like 'cleanup by a loose foreign key' do + let!(:model) { create(:ci_pipeline, user: create(:user)) } + let!(:parent) { model.user } + end end diff --git a/spec/models/external_pull_request_spec.rb b/spec/models/external_pull_request_spec.rb index bac2c369d7d..b141600c4fd 100644 --- a/spec/models/external_pull_request_spec.rb +++ b/spec/models/external_pull_request_spec.rb @@ -232,4 +232,8 @@ RSpec.describe ExternalPullRequest do 'with space/README.md'] end end + + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :external_pull_request } + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 253b7d65d33..e1db1b3cf3e 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -5042,4 +5042,8 @@ RSpec.describe MergeRequest, factory_default: :keep do expect(described_class.from_fork).to eq([fork_mr]) end end + + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :merge_request } + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 19e09c99a15..39666551fb0 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -110,8 +110,8 @@ RSpec.describe User do it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) } - it { is_expected.to have_many(:builds).dependent(:nullify) } - it { is_expected.to have_many(:pipelines).dependent(:nullify) } + it { is_expected.to have_many(:builds) } + it { is_expected.to have_many(:pipelines) } it { is_expected.to have_many(:chat_names).dependent(:destroy) } it { is_expected.to have_many(:uploads) } it { is_expected.to have_many(:reported_abuse_reports).dependent(:destroy).class_name('AbuseReport') } @@ -1616,6 +1616,46 @@ RSpec.describe User do end end + describe 'enabled_static_object_token' do + let_it_be(:static_object_token) { 'ilqx6jm1u945macft4eff0nw' } + + it 'returns incoming email token when supported' do + allow(Gitlab::CurrentSettings).to receive(:static_objects_external_storage_enabled?).and_return(true) + + user = create(:user, static_object_token: static_object_token) + + expect(user.enabled_static_object_token).to eq(static_object_token) + end + + it 'returns `nil` when not supported' do + allow(Gitlab::CurrentSettings).to receive(:static_objects_external_storage_enabled?).and_return(false) + + user = create(:user, static_object_token: static_object_token) + + expect(user.enabled_static_object_token).to be_nil + end + end + + describe 'enabled_incoming_email_token' do + let_it_be(:incoming_email_token) { 'ilqx6jm1u945macft4eff0nw' } + + it 'returns incoming email token when supported' do + allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(true) + + user = create(:user, incoming_email_token: incoming_email_token) + + expect(user.enabled_incoming_email_token).to eq(incoming_email_token) + end + + it 'returns `nil` when not supported' do + allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(false) + + user = create(:user, incoming_email_token: incoming_email_token) + + expect(user.enabled_incoming_email_token).to be_nil + end + end + describe '#recently_sent_password_reset?' do it 'is false when reset_password_sent_at is nil' do user = build_stubbed(:user, reset_password_sent_at: nil) @@ -6289,4 +6329,8 @@ RSpec.describe User do expect(user.user_readme).to be(nil) end end + + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :user } + end end diff --git a/spec/requests/groups/crm/contacts_controller_spec.rb b/spec/requests/groups/crm/contacts_controller_spec.rb new file mode 100644 index 00000000000..a4b2a28e77a --- /dev/null +++ b/spec/requests/groups/crm/contacts_controller_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::Crm::ContactsController do + let_it_be(:user) { create(:user) } + + shared_examples 'response with 404 status' do + it 'returns 404' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'ok response with index template' do + it 'renders the index template' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:index) + end + end + + shared_examples 'ok response with index template if authorized' do + context 'private group' do + let(:group) { create(:group, :private) } + + context 'with authorized user' do + before do + group.add_reporter(user) + sign_in(user) + end + + context 'when feature flag is enabled' do + it_behaves_like 'ok response with index template' + end + + context 'when feature flag is not enabled' do + before do + stub_feature_flags(customer_relations: false) + end + + it_behaves_like 'response with 404 status' + end + end + + context 'with unauthorized user' do + before do + sign_in(user) + end + + it_behaves_like 'response with 404 status' + end + + context 'with anonymous user' do + it 'blah' do + subject + + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + context 'public group' do + let(:group) { create(:group, :public) } + + context 'with anonymous user' do + it_behaves_like 'ok response with index template' + end + end + end + + describe 'GET #index' do + subject do + get group_crm_contacts_path(group) + response + end + + it_behaves_like 'ok response with index template if authorized' + end + + describe 'GET #new' do + subject do + get new_group_crm_contact_path(group) + response + end + + it_behaves_like 'ok response with index template if authorized' + end + + describe 'GET #edit' do + subject do + get edit_group_crm_contact_path(group, id: 1) + response + end + + it_behaves_like 'ok response with index template if authorized' + end +end diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index 4c09c1c2a3b..3d52ed30c62 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -213,6 +213,12 @@ RSpec.shared_examples 'namespace traversal scopes' do it { is_expected.to contain_exactly(deep_nested_group_1, deep_nested_group_2) } end + + context 'with offset and limit' do + subject { described_class.where(id: [group_1, group_2]).offset(1).limit(1).self_and_descendants } + + it { is_expected.to contain_exactly(group_2, nested_group_2, deep_nested_group_2) } + end end describe '.self_and_descendants' do @@ -242,6 +248,19 @@ RSpec.shared_examples 'namespace traversal scopes' do it { is_expected.to contain_exactly(deep_nested_group_1.id, deep_nested_group_2.id) } end + + context 'with offset and limit' do + subject do + described_class + .where(id: [group_1, group_2]) + .limit(1) + .offset(1) + .self_and_descendant_ids + .pluck(:id) + end + + it { is_expected.to contain_exactly(group_2.id, nested_group_2.id, deep_nested_group_2.id) } + end end describe '.self_and_descendant_ids' do diff --git a/yarn.lock b/yarn.lock index 2c710292b51..2e52e595722 100644 --- a/yarn.lock +++ b/yarn.lock @@ -914,27 +914,28 @@ stylelint-declaration-strict-value "1.7.7" stylelint-scss "3.18.0" -"@gitlab/svgs@1.226.0": - version "1.226.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.226.0.tgz#f590545df4bf871a9653f2ac93029fbc1bfd2788" - integrity sha512-oSPbwkPJ8yDttTy+zcqtA9TIPOGiTUXlgIf1XnlrMHUoQmzUUqkJMql6LDcP4xAqX0n+7Kinoxl8gmMSwMKYjw== +"@gitlab/svgs@1.229.0": + version "1.229.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.229.0.tgz#1ee863320ea3e0ff6b670dac59373b8b49e31388" + integrity sha512-10OLT3gj9AQ5DmcqaRcblkIY1dwr0danjaKl+hzjcA9sjvGuTNn3P/rQZglFanM2eI6MkoHG1YP7UeSs7cFuCQ== "@gitlab/tributejs@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8" integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw== -"@gitlab/ui@32.43.2": - version "32.43.2" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-32.43.2.tgz#df1e86493354db61da60e2eb8e9e67c79e9c402c" - integrity sha512-BJZJeXqi9MrJ5xAQ8rA2t95udSxsRGPeeCzlb6HI26j8LAJpVj9ArbiehoMZ45aGpX0+gnElMUGNOcZ8XHlQqw== +"@gitlab/ui@32.49.0": + version "32.49.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-32.49.0.tgz#d899e2e2487bb0e23a408386acc04eac14808de5" + integrity sha512-QB1M1/8vc1o0hAm5tg8tWIEcj5Isy2JxHFWKtDNnFqPvbb0QNBsoEazz9DNra3dNSRzt8zF8NJPqmuRT8WAvQA== dependencies: "@babel/standalone" "^7.0.0" bootstrap-vue "2.20.1" copy-to-clipboard "^3.0.8" - dompurify "^2.3.3" + dompurify "^2.3.4" echarts "^5.2.1" highlight.js "^10.6.0" + iframe-resizer "^4.3.2" js-beautify "^1.8.8" lodash "^4.17.20" portal-vue "^2.1.6" @@ -3789,10 +3790,10 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== -core-js@^3.19.3: - version "3.19.3" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.19.3.tgz#6df8142a996337503019ff3235a7022d7cdf4559" - integrity sha512-LeLBMgEGSsG7giquSzvgBrTS7V5UL6ks3eQlUSbN8dJStlLFiRzUm5iqsRyzUB8carhfKjkJ2vzKqE6z1Vga9g== +core-js@^3.20.0: + version "3.20.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.20.0.tgz#1c5ac07986b8d15473ab192e45a2e115a4a95b79" + integrity sha512-KjbKU7UEfg4YPpskMtMXPhUKn7m/1OdTHTVjy09ScR2LVaoUXe8Jh0UdvN2EKUR6iKTJph52SJP95mAB0MnVLQ== core-js@~2.3.0: version "2.3.0" @@ -4923,7 +4924,7 @@ dompurify@2.3.3: resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.3.tgz#c1af3eb88be47324432964d8abc75cf4b98d634c" integrity sha512-dqnqRkPMAjOZE0FogZ+ceJNM2dZ3V/yNOuFB7+39qpO93hHhfRpHw3heYQC7DPK9FqbQTfBKUJhiSfz4MvXYwg== -dompurify@^2.3.3, dompurify@^2.3.4: +dompurify@^2.3.4: version "2.3.4" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.4.tgz#1cf5cf0105ccb4debdf6db162525bd41e6ddacc6" integrity sha512-6BVcgOAVFXjI0JTjEvZy901Rghm+7fDQOrNIcxB4+gdhj6Kwp6T9VBhBY/AbagKHJocRkDYGd6wvI+p4/10xtQ== @@ -5041,7 +5042,12 @@ emittery@^0.7.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451" integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ== -emoji-regex@^7.0.1, emoji-regex@^7.0.3: +emoji-regex@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.0.0.tgz#96559e19f82231b436403e059571241d627c42b8" + integrity sha512-KmJa8l6uHi1HrBI34udwlzZY1jOEuID/ft4d8BSSEdRyap7PwBEt910453PJa5MuGvxkLqlt4Uvhu7tttFHViw== + +emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== @@ -6613,6 +6619,11 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= +iframe-resizer@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/iframe-resizer/-/iframe-resizer-4.3.2.tgz#42dd88345d18b9e377b6044dddb98c664ab0ce6b" + integrity sha512-gOWo2hmdPjMQsQ+zTKbses08mDfDEMh4NneGQNP4qwePYujY1lguqP6gnbeJkf154gojWlBhIltlgnMfYjGHWA== + ignore-by-default@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" |