diff options
162 files changed, 3996 insertions, 1310 deletions
diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index bb546632335..7f1a7ff4cb6 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -109,7 +109,6 @@ linters: - 'app/views/groups/runners/edit.html.haml' - 'app/views/groups/settings/_advanced.html.haml' - 'app/views/groups/settings/_lfs.html.haml' - - 'app/views/help/_shortcuts.html.haml' - 'app/views/help/index.html.haml' - 'app/views/help/instance_configuration.html.haml' - 'app/views/help/instance_configuration/_gitlab_ci.html.haml' diff --git a/CHANGELOG.md b/CHANGELOG.md index d0688ebf570..ea9b789ce05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 13.8.4 (2021-02-11) + +### Security (9 changes) + +- Cancel running and pending jobs when a project is deleted. !1220 +- Prevent Denial of Service Attack on gitlab-shell. +- Prevent exposure of confidential issue titles in file browser. +- Updates authorization for linting API. +- Check user access on API merge request read actions. +- Limit daily invitations to groups and projects. +- Enforce the analytics enabled project setting for project-level analytics features. +- Perform SSL verification for FortiTokenCloud Integration. +- Prevent Server-side Request Forgery for Prometheus when secured by Google IAP. + + ## 13.8.3 (2021-02-05) ### Fixed (2 changes) @@ -387,6 +402,21 @@ entry. - Add verbiage + link sast to show it's in core. !51935 +## 13.7.7 (2021-02-11) + +### Security (9 changes) + +- Cancel running and pending jobs when a project is deleted. !1220 +- Prevent Denial of Service Attack on gitlab-shell. +- Prevent exposure of confidential issue titles in file browser. +- Updates authorization for linting API. +- Check user access on API merge request read actions. +- Limit daily invitations to groups and projects. +- Enforce the analytics enabled project setting for project-level analytics features. +- Perform SSL verification for FortiTokenCloud Integration. +- Prevent Server-side Request Forgery for Prometheus when secured by Google IAP. + + ## 13.7.6 (2021-02-01) ### Security (5 changes) @@ -908,6 +938,19 @@ entry. - Update GitLab Workhorse to v8.57.0. +## 13.6.7 (2021-02-11) + +### Security (7 changes) + +- Cancel running and pending jobs when a project is deleted. !1220 +- Updates authorization for linting API. +- Prevent exposure of confidential issue titles in file browser. +- Check user access on API merge request read actions. +- Prevent Denial of Service Attack on gitlab-shell. +- Limit daily invitations to groups and projects. +- Prevent Server-side Request Forgery for Prometheus when secured by Google IAP. + + ## 13.6.6 (2021-02-01) ### Security (5 changes) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 76e955d251d..b102cab1df4 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -88ef3e7f64498ae3574f29b0705c29cf3b4e9311 +d0a79053ba4fef55b59543b99327fc89aed64876 diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 3da85016902..592a1a89678 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -13.16.0 +13.16.1 diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index 6cdf083378b..d586c0c8dd0 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -3,12 +3,11 @@ import Cookies from 'js-cookie'; import Mousetrap from 'mousetrap'; import Vue from 'vue'; import { flatten } from 'lodash'; -import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils'; -import axios from '../../lib/utils/axios_utils'; -import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility'; -import findAndFollowLink from '../../lib/utils/navigation_utility'; +import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility'; +import findAndFollowLink from '~/lib/utils/navigation_utility'; +import { parseBoolean } from '~/lib/utils/common_utils'; + import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; -import ShortcutsToggle from './shortcuts_toggle.vue'; import { keysFor, TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY } from './keybindings'; const defaultStopCallback = Mousetrap.prototype.stopCallback; @@ -20,15 +19,6 @@ Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo return defaultStopCallback.call(this, e, element, combo); }; -function initToggleButton() { - return new Vue({ - el: document.querySelector('.js-toggle-shortcuts'), - render(createElement) { - return createElement(ShortcutsToggle); - }, - }); -} - /** * The key used to save and fetch the local Mousetrap instance * attached to a `<textarea>` element using `jQuery.data` @@ -65,7 +55,8 @@ function getToolbarBtnToShortcutsMap($textarea) { export default class Shortcuts { constructor() { this.onToggleHelp = this.onToggleHelp.bind(this); - this.enabledHelp = []; + this.helpModalElement = null; + this.helpModalVueInstance = null; Mousetrap.bind('?', this.onToggleHelp); Mousetrap.bind('s', Shortcuts.focusSearch); @@ -107,11 +98,33 @@ export default class Shortcuts { } onToggleHelp(e) { - if (e.preventDefault) { + if (e?.preventDefault) { e.preventDefault(); } - Shortcuts.toggleHelp(this.enabledHelp); + if (this.helpModalElement && this.helpModalVueInstance) { + this.helpModalVueInstance.$destroy(); + this.helpModalElement.remove(); + this.helpModalElement = null; + this.helpModalVueInstance = null; + } else { + this.helpModalElement = document.createElement('div'); + document.body.append(this.helpModalElement); + + this.helpModalVueInstance = new Vue({ + el: this.helpModalElement, + components: { + ShortcutsHelp: () => import('./shortcuts_help.vue'), + }, + render: (createElement) => { + return createElement('shortcuts-help', { + on: { + hidden: this.onToggleHelp, + }, + }); + }, + }); + } } static onTogglePerfBar(e) { @@ -144,34 +157,6 @@ export default class Shortcuts { $(document).triggerHandler('markdown-preview:toggle', [e]); } - static toggleHelp(location) { - const $modal = $('#modal-shortcuts'); - - if ($modal.length) { - $modal.modal('toggle'); - return null; - } - - return axios - .get(gon.shortcuts_path, { - responseType: 'text', - }) - .then(({ data }) => { - $.globalEval(data, { nonce: getCspNonceValue() }); - - if (location && location.length > 0) { - const results = []; - for (let i = 0, len = location.length; i < len; i += 1) { - results.push($(location[i]).show()); - } - return results; - } - - return $('.js-more-help-button').remove(); - }) - .then(initToggleButton); - } - focusFilter(e) { if (!this.filterInput) { this.filterInput = $('input[type=search]', '.nav-controls'); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue new file mode 100644 index 00000000000..1277dd0ed37 --- /dev/null +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue @@ -0,0 +1,525 @@ +<script> +/* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlIcon, GlModal } from '@gitlab/ui'; +import ShortcutsToggle from './shortcuts_toggle.vue'; + +export default { + components: { + GlIcon, + GlModal, + ShortcutsToggle, + }, + computed: { + ctrlCharacter() { + return window.gl.client.isMac ? '⌘' : 'ctrl'; + }, + onDotCom() { + return window.gon.dot_com; + }, + }, +}; +</script> +<template> + <gl-modal + modal-id="keyboard-shortcut-modal" + size="lg" + data-testid="modal-shortcuts" + :visible="true" + :hide-footer="true" + @hidden="$emit('hidden')" + > + <template #modal-title> + <shortcuts-toggle /> + </template> + <div class="row"> + <div class="col-lg-4"> + <table class="shortcut-mappings text-2"> + <tbody> + <tr> + <th></th> + <th>{{ __('Global Shortcuts') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>?</kbd> + </td> + <td>{{ __('Toggle this dialog') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift p</kbd> + </td> + <td>{{ __('Go to your projects') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift g</kbd> + </td> + <td>{{ __('Go to your groups') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift a</kbd> + </td> + <td>{{ __('Go to the activity feed') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift l</kbd> + </td> + <td>{{ __('Go to the milestone list') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift s</kbd> + </td> + <td>{{ __('Go to your snippets') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>s</kbd> + / + <kbd>/</kbd> + </td> + <td>{{ __('Start search') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift i</kbd> + </td> + <td>{{ __('Go to your issues') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift m</kbd> + </td> + <td>{{ __('Go to your merge requests') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>shift t</kbd> + </td> + <td>{{ __('Go to your To-Do list') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>p</kbd> + <kbd>b</kbd> + </td> + <td>{{ __('Toggle the Performance Bar') }}</td> + </tr> + <tr v-if="onDotCom"> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>x</kbd> + </td> + <td>{{ __('Toggle GitLab Next') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Editing') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>{{ ctrlCharacter }} shift p</kbd> + </td> + <td>{{ __('Toggle Markdown preview') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-up" /> + </kbd> + </td> + <td> + {{ __('Edit your most recent comment in a thread (from an empty textarea)') }} + </td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Wiki') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>e</kbd> + </td> + <td>{{ __('Edit wiki page') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Repository Graph') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-left" /> + </kbd> + / + <kbd>h</kbd> + </td> + <td>{{ __('Scroll left') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-right" /> + </kbd> + / + <kbd>l</kbd> + </td> + <td>{{ __('Scroll right') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-up" /> + </kbd> + / + <kbd>k</kbd> + </td> + <td>{{ __('Scroll up') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-down" /> + </kbd> + / + <kbd>j</kbd> + </td> + <td>{{ __('Scroll down') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + shift + <gl-icon name="arrow-up" /> + / k + </kbd> + </td> + <td>{{ __('Scroll to top') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + shift + <gl-icon name="arrow-down" /> + / j + </kbd> + </td> + <td>{{ __('Scroll to bottom') }}</td> + </tr> + </tbody> + </table> + </div> + <div class="col-lg-4"> + <table class="shortcut-mappings text-2"> + <tbody> + <tr> + <th></th> + <th>{{ __('Project') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>p</kbd> + </td> + <td>{{ __("Go to the project's overview page") }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>v</kbd> + </td> + <td>{{ __("Go to the project's activity feed") }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>r</kbd> + </td> + <td>{{ __('Go to releases') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>f</kbd> + </td> + <td>{{ __('Go to files') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>t</kbd> + </td> + <td>{{ __('Go to find file') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>c</kbd> + </td> + <td>{{ __('Go to commits') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>n</kbd> + </td> + <td>{{ __('Go to repository graph') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>d</kbd> + </td> + <td>{{ __('Go to repository charts') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>i</kbd> + </td> + <td>{{ __('Go to issues') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>i</kbd> + </td> + <td>{{ __('New issue') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>b</kbd> + </td> + <td>{{ __('Go to issue boards') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>m</kbd> + </td> + <td>{{ __('Go to merge requests') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>j</kbd> + </td> + <td>{{ __('Go to jobs') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>l</kbd> + </td> + <td>{{ __('Go to metrics') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>e</kbd> + </td> + <td>{{ __('Go to environments') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>k</kbd> + </td> + <td>{{ __('Go to kubernetes') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>s</kbd> + </td> + <td>{{ __('Go to snippets') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>g</kbd> + <kbd>w</kbd> + </td> + <td>{{ __('Go to wiki') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Project Files') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-up" /> + </kbd> + </td> + <td>{{ __('Move selection up') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd> + <gl-icon name="arrow-down" /> + </kbd> + </td> + <td>{{ __('Move selection down') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>enter</kbd> + </td> + <td>{{ __('Open Selection') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>esc</kbd> + </td> + <td>{{ __('Go back (while searching for files)') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>y</kbd> + </td> + <td>{{ __('Go to file permalink (while viewing a file)') }}</td> + </tr> + </tbody> + </table> + </div> + <div class="col-lg-4"> + <table class="shortcut-mappings text-2"> + <tbody> + <tr> + <th></th> + <th>{{ __('Epics, Issues, and Merge Requests') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>r</kbd> + </td> + <td>{{ __('Comment/Reply (quoting selected text)') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>e</kbd> + </td> + <td>{{ __('Edit description') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>l</kbd> + </td> + <td>{{ __('Change label') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Issues and Merge Requests') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>a</kbd> + </td> + <td>{{ __('Change assignee') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>m</kbd> + </td> + <td>{{ __('Change milestone') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Merge Requests') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>]</kbd> + / + <kbd>j</kbd> + </td> + <td>{{ __('Next file in diff') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>[</kbd> + / + <kbd>k</kbd> + </td> + <td>{{ __('Previous file in diff') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>{{ ctrlCharacter }} p</kbd> + </td> + <td>{{ __('Go to file') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>n</kbd> + </td> + <td>{{ __('Next unresolved discussion') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>p</kbd> + </td> + <td>{{ __('Previous unresolved discussion') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>b</kbd> + </td> + <td>{{ __('Copy source branch name') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Merge Request Commits') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>c</kbd> + </td> + <td>{{ __('Next commit') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>x</kbd> + </td> + <td>{{ __('Previous commit') }}</td> + </tr> + </tbody> + <tbody> + <tr> + <th></th> + <th>{{ __('Web IDE') }}</th> + </tr> + <tr> + <td class="shortcut"> + <kbd>{{ ctrlCharacter }} p</kbd> + </td> + <td>{{ __('Go to file') }}</td> + </tr> + <tr> + <td class="shortcut"> + <kbd>{{ ctrlCharacter }} enter</kbd> + </td> + <td>{{ __('Commit (when editing commit message)') }}</td> + </tr> + </tbody> + </table> + </div> + </div> + </gl-modal> +</template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index bc2b2d6d5d0..e57d42dc63c 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -1,9 +1,8 @@ <script> import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { isEmpty } from 'lodash'; import Autosize from 'autosize'; -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { deprecatedCreateFlash as Flash } from '~/flash'; @@ -34,6 +33,10 @@ export default { TimelineEntryItem, GlIcon, CommentFieldLayout, + GlFormCheckbox, + }, + directives: { + GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin(), issuableStateMixin], props: { @@ -46,8 +49,8 @@ export default { return { note: '', noteType: constants.COMMENT, + noteIsConfidential: false, isSubmitting: false, - isSubmitButtonDisabled: true, }; }, computed: { @@ -80,6 +83,9 @@ export default { canCreateNote() { return this.getNoteableData.current_user.can_create_note; }, + canSetConfidential() { + return this.getNoteableData.current_user.can_update; + }, issueActionButtonTitle() { const openOrClose = this.isOpen ? 'close' : 'reopen'; @@ -146,13 +152,11 @@ export default { hasCloseAndCommentButton() { return !this.glFeatures.removeCommentCloseReopen; }, - }, - watch: { - note(newNote) { - this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); + confidentialNotesEnabled() { + return Boolean(this.glFeatures.confidentialNotes); }, - isSubmitting(newValue) { - this.setIsSubmitButtonDisabled(this.note, newValue); + disableSubmitButton() { + return this.note.length === 0 || this.isSubmitting; }, }, mounted() { @@ -173,13 +177,6 @@ export default { 'reopenIssuable', 'toggleIssueLocalState', ]), - setIsSubmitButtonDisabled(note, isSubmitting) { - if (!isEmpty(note) && !isSubmitting) { - this.isSubmitButtonDisabled = false; - } else { - this.isSubmitButtonDisabled = true; - } - }, handleSave(withIssueAction) { if (this.note.length) { const noteData = { @@ -189,6 +186,7 @@ export default { note: { noteable_type: this.noteableType, noteable_id: this.getNoteableData.id, + confidential: this.noteIsConfidential, note: this.note, }, merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, @@ -252,6 +250,7 @@ export default { if (shouldClear) { this.note = ''; + this.noteIsConfidential = false; this.resizeTextarea(); this.$refs.markdownField.previewMarkdown = false; } @@ -340,11 +339,26 @@ export default { </markdown-field> </comment-field-layout> <div class="note-form-actions"> + <gl-form-checkbox + v-if="confidentialNotesEnabled && canSetConfidential" + v-model="noteIsConfidential" + class="gl-mb-6" + data-testid="confidential-note-checkbox" + > + {{ s__('Notes|Make this comment confidential') }} + <gl-icon + v-gl-tooltip:tooltipcontainer.bottom + name="question" + :size="16" + :title="s__('Notes|Confidential comments are only visible to project members')" + class="gl-text-gray-500" + /> + </gl-form-checkbox> <div class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" > <gl-button - :disabled="isSubmitButtonDisabled" + :disabled="disableSubmitButton" class="js-comment-button js-comment-submit-button" data-qa-selector="comment_button" data-testid="comment-button" @@ -357,7 +371,7 @@ export default { >{{ commentButtonTitle }}</gl-button > <gl-button - :disabled="isSubmitButtonDisabled" + :disabled="disableSubmitButton" name="button" category="primary" variant="success" diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 17a995018d3..23b1f1e2ac1 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -210,9 +210,9 @@ export default { v-gl-tooltip:tooltipcontainer.bottom data-testid="confidentialIndicator" name="eye-slash" - :size="14" - :title="s__('Notes|Private comments are accessible by internal staff only')" - class="gl-ml-1 gl-text-gray-700 align-middle" + :size="16" + :title="s__('Notes|This comment is confidential and only visible to project members')" + class="gl-ml-1 gl-text-orange-700 align-middle" /> <slot name="extra-controls"></slot> <gl-loading-icon diff --git a/app/assets/javascripts/pages/projects/security/configuration/index.js b/app/assets/javascripts/pages/projects/security/configuration/index.js new file mode 100644 index 00000000000..101cb8356b2 --- /dev/null +++ b/app/assets/javascripts/pages/projects/security/configuration/index.js @@ -0,0 +1,3 @@ +import { initStaticSecurityConfiguration } from '~/security_configuration'; + +initStaticSecurityConfiguration(document.querySelector('#js-security-configuration-static')); diff --git a/app/assets/javascripts/pages/projects/serverless/index.js b/app/assets/javascripts/pages/projects/serverless/index.js index a883737ac9b..640301dd478 100644 --- a/app/assets/javascripts/pages/projects/serverless/index.js +++ b/app/assets/javascripts/pages/projects/serverless/index.js @@ -1,7 +1,5 @@ import ServerlessBundle from '~/serverless/serverless_bundle'; import initServerlessSurveyBanner from '~/serverless/survey_banner'; -document.addEventListener('DOMContentLoaded', () => { - initServerlessSurveyBanner(); - new ServerlessBundle(); // eslint-disable-line no-new -}); +initServerlessSurveyBanner(); +new ServerlessBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js index 37ced72861e..f59b9d7a9f5 100644 --- a/app/assets/javascripts/registry/explorer/constants/list.js +++ b/app/assets/javascripts/registry/explorer/constants/list.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; // Translations strings @@ -35,8 +35,6 @@ export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( 'ContainerRegistry|%{title} was successfully scheduled for deletion', ); -export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories'); -export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name'); export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.'); export const EMPTY_RESULT_MESSAGE = s__( 'ContainerRegistry|To widen your search, change or remove the filters above.', @@ -47,3 +45,9 @@ export const EMPTY_RESULT_MESSAGE = s__( export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED'; export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED'; export const GRAPHQL_PAGE_SIZE = 10; + +export const SORT_FIELDS = [ + { orderBy: 'UPDATED', label: __('Updated') }, + { orderBy: 'CREATED', label: __('Created') }, + { orderBy: 'NAME', label: __('Name') }, +]; diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql index 8b6d778c655..01cb7fa1cab 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql @@ -6,9 +6,17 @@ query getContainerRepositoriesDetails( $after: String $before: String $isGroupPage: Boolean! + $sort: ContainerRepositorySort ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { - containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + containerRepositories( + name: $name + after: $after + before: $before + first: $first + last: $last + sort: $sort + ) { nodes { id tagsCount @@ -16,7 +24,14 @@ query getContainerRepositoriesDetails( } } group(fullPath: $fullPath) @include(if: $isGroupPage) { - containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + containerRepositories( + name: $name + after: $after + before: $before + first: $first + last: $last + sort: $sort + ) { nodes { id tagsCount diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index d362b79789b..c710d125797 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -7,12 +7,12 @@ import { GlLink, GlAlert, GlSkeletonLoader, - GlSearchBoxByClick, } from '@gitlab/ui'; import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import Tracking from '~/tracking'; import createFlash from '~/flash'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import RegistryHeader from '../components/list_page/registry_header.vue'; import DeleteImage from '../components/delete_image.vue'; @@ -25,12 +25,11 @@ import { CONNECTION_ERROR_MESSAGE, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, - SEARCH_PLACEHOLDER_TEXT, - IMAGE_REPOSITORY_LIST_LABEL, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, + SORT_FIELDS, } from '../constants/index'; export default { @@ -58,9 +57,9 @@ export default { GlLink, GlAlert, GlSkeletonLoader, - GlSearchBoxByClick, RegistryHeader, DeleteImage, + RegistrySearch, }, directives: { GlTooltip: GlTooltipDirective, @@ -77,11 +76,10 @@ export default { CONNECTION_ERROR_MESSAGE, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, - SEARCH_PLACEHOLDER_TEXT, - IMAGE_REPOSITORY_LIST_LABEL, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, }, + searchConfig: SORT_FIELDS, apollo: { baseImages: { query: getContainerRepositoriesQuery, @@ -123,7 +121,8 @@ export default { containerRepositoriesCount: 0, itemToDelete: {}, deleteAlertType: null, - searchValue: null, + filter: [], + sorting: { orderBy: 'UPDATED', sort: 'desc' }, name: null, mutationLoading: false, fetchAdditionalDetails: false, @@ -142,6 +141,7 @@ export default { queryVariables() { return { name: this.name, + sort: this.sortBy, fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, isGroupPage: this.config.isGroupPage, first: GRAPHQL_PAGE_SIZE, @@ -166,6 +166,10 @@ export default { ? DELETE_IMAGE_SUCCESS_MESSAGE : DELETE_IMAGE_ERROR_MESSAGE; }, + sortBy() { + const { orderBy, sort } = this.sorting; + return `${orderBy}_${sort}`.toUpperCase(); + }, }, mounted() { // If the two graphql calls - which are not batched - resolve togheter we will have a race @@ -231,6 +235,16 @@ export default { this.track('confirm_delete'); this.mutationLoading = true; }, + updateSorting(value) { + this.sorting = { + ...this.sorting, + ...value, + }; + }, + doFilter() { + const search = this.filter.find((i) => i.type === 'filtered-search-term'); + this.name = search?.value?.data; + }, }, }; </script> @@ -283,6 +297,16 @@ export default { </template> </registry-header> + <registry-search + :filter="filter" + :sorting="sorting" + :tokens="[]" + :sortable-fields="$options.searchConfig" + @sorting:changed="updateSorting" + @filter:changed="filter = $event" + @filter:submit="doFilter" + /> + <div v-if="isLoading" class="gl-mt-5"> <gl-skeleton-loader v-for="index in $options.loader.repeat" @@ -298,20 +322,6 @@ export default { </div> <template v-else> <template v-if="images.length > 0 || name"> - <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader"> - <div class="gl-flex-fill-1"> - <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> - </div> - <div> - <gl-search-box-by-click - v-model="searchValue" - :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT" - @clear="name = null" - @submit="name = $event" - /> - </div> - </div> - <image-list v-if="images.length" :images="images" diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue new file mode 100644 index 00000000000..513a7353d28 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -0,0 +1,23 @@ +<script> +import ConfigurationTable from './configuration_table.vue'; + +export default { + components: { + ConfigurationTable, + }, +}; +</script> + +<template> + <article> + <header> + <h4 class="gl-my-5"> + {{ __('Security Configuration') }} + </h4> + <h5 class="gl-font-lg gl-mt-7"> + {{ s__('SecurityConfiguration|Testing & Compliance') }} + </h5> + </header> + <configuration-table /> + </article> +</template> diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue new file mode 100644 index 00000000000..2d9e8e63826 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue @@ -0,0 +1,97 @@ +<script> +import { GlLink, GlSprintf, GlTable, GlAlert } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_DAST, + REPORT_TYPE_DEPENDENCY_SCANNING, + REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_LICENSE_COMPLIANCE, +} from '~/vue_shared/security_reports/constants'; +import ManageSast from './manage_sast.vue'; +import Upgrade from './upgrade.vue'; +import { features } from './features_constants'; + +const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!'; +const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`; + +export default { + components: { + GlLink, + GlSprintf, + GlTable, + GlAlert, + }, + data: () => ({ + features, + errorMessage: '', + }), + methods: { + getFeatureDocumentationLinkLabel(item) { + return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), { + featureName: item.name, + }); + }, + onError(value) { + this.errorMessage = value; + }, + getComponentForItem(item) { + const COMPONENTS = { + [REPORT_TYPE_SAST]: ManageSast, + [REPORT_TYPE_DAST]: Upgrade, + [REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade, + [REPORT_TYPE_CONTAINER_SCANNING]: Upgrade, + [REPORT_TYPE_COVERAGE_FUZZING]: Upgrade, + [REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade, + }; + + return COMPONENTS[item.type]; + }, + }, + table: { + fields: [ + { + key: 'feature', + label: s__('SecurityConfiguration|Security Control'), + thClass, + }, + { + key: 'manage', + label: s__('SecurityConfiguration|Manage'), + thClass, + }, + ], + items: features, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="errorMessage" variant="danger" :dismissible="false"> + {{ errorMessage }} + </gl-alert> + <gl-table :items="$options.table.items" :fields="$options.table.fields" stacked="md"> + <template #cell(feature)="{ item }"> + <div class="gl-text-gray-900"> + {{ item.name }} + </div> + <div> + {{ item.description }} + <gl-link + target="_blank" + :href="item.link" + :aria-label="getFeatureDocumentationLinkLabel(item)" + > + {{ s__('SecurityConfiguration|More information') }} + </gl-link> + </div> + </template> + + <template #cell(manage)="{ item }"> + <component :is="getComponentForItem(item)" :data-testid="item.type" @error="onError" /> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/security_configuration/components/features_constants.js b/app/assets/javascripts/security_configuration/components/features_constants.js new file mode 100644 index 00000000000..c21c18fbf60 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/features_constants.js @@ -0,0 +1,112 @@ +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +import { + REPORT_TYPE_SAST, + REPORT_TYPE_DAST, + REPORT_TYPE_SECRET_DETECTION, + REPORT_TYPE_DEPENDENCY_SCANNING, + REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_LICENSE_COMPLIANCE, +} from '~/vue_shared/security_reports/constants'; + +/** + * Translations & helpPagePaths for Static Security Configuration Page + */ +export const SAST_NAME = s__('Static Application Security Testing (SAST)'); +export const SAST_DESCRIPTION = s__('Analyze your source code for known vulnerabilities.'); +export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); + +export const DAST_NAME = s__('Dynamic Application Security Testing (DAST)'); +export const DAST_DESCRIPTION = s__('Analyze a review version of your web application.'); +export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); + +export const SECRET_DETECTION_NAME = s__('Secret Detection'); +export const SECRET_DETECTION_DESCRIPTION = s__( + 'Analyze your source code and git history for secrets.', +); +export const SECRET_DETECTION_HELP_PATH = helpPagePath( + 'user/application_security/secret_detection/index', +); + +export const DEPENDENCY_SCANNING_NAME = s__('Dependency Scanning'); +export const DEPENDENCY_SCANNING_DESCRIPTION = s__( + 'Analyze your dependencies for known vulnerabilities.', +); +export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/dependency_scanning/index', +); + +export const CONTAINER_SCANNING_NAME = s__('Container Scanning'); +export const CONTAINER_SCANNING_DESCRIPTION = s__( + 'Check your Docker images for known vulnerabilities.', +); +export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/container_scanning/index', +); + +export const COVERAGE_FUZZING_NAME = s__('Coverage Fuzzing'); +export const COVERAGE_FUZZING_DESCRIPTION = s__( + 'Find bugs in your code with coverage-guided fuzzing.', +); +export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( + 'user/application_security/coverage_fuzzing/index', +); + +export const LICENSE_COMPLIANCE_NAME = s__('License Compliance'); +export const LICENSE_COMPLIANCE_DESCRIPTION = s__( + 'Search your project dependencies for their licenses and apply policies.', +); +export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath( + 'user/compliance/license_compliance/index', +); + +export const UPGRADE_CTA = s__( + 'SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}', +); + +export const features = [ + { + name: SAST_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + type: REPORT_TYPE_SAST, + }, + { + name: DAST_NAME, + description: DAST_DESCRIPTION, + helpPath: DAST_HELP_PATH, + type: REPORT_TYPE_DAST, + }, + { + name: SECRET_DETECTION_NAME, + description: SECRET_DETECTION_DESCRIPTION, + helpPath: SECRET_DETECTION_HELP_PATH, + type: REPORT_TYPE_SECRET_DETECTION, + }, + { + name: DEPENDENCY_SCANNING_NAME, + description: DEPENDENCY_SCANNING_DESCRIPTION, + helpPath: DEPENDENCY_SCANNING_HELP_PATH, + type: REPORT_TYPE_DEPENDENCY_SCANNING, + }, + { + name: CONTAINER_SCANNING_NAME, + description: CONTAINER_SCANNING_DESCRIPTION, + helpPath: CONTAINER_SCANNING_HELP_PATH, + type: REPORT_TYPE_CONTAINER_SCANNING, + }, + { + name: COVERAGE_FUZZING_NAME, + description: COVERAGE_FUZZING_DESCRIPTION, + helpPath: COVERAGE_FUZZING_HELP_PATH, + type: REPORT_TYPE_COVERAGE_FUZZING, + }, + { + name: LICENSE_COMPLIANCE_NAME, + description: LICENSE_COMPLIANCE_DESCRIPTION, + helpPath: LICENSE_COMPLIANCE_HELP_PATH, + type: REPORT_TYPE_LICENSE_COMPLIANCE, + }, +]; diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue new file mode 100644 index 00000000000..49bd3ba64e5 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue @@ -0,0 +1,57 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +export default { + components: { + GlButton, + }, + inject: { + projectPath: { + from: 'projectPath', + default: '', + }, + }, + data: () => ({ + isLoading: false, + }), + methods: { + async mutate() { + this.isLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: configureSastMutation, + variables: { + input: { + projectPath: this.projectPath, + configuration: { global: [], pipeline: [], analyzers: [] }, + }, + }, + }); + const { errors, successPath } = data.configureSast; + + if (errors.length > 0) { + throw new Error(errors[0]); + } + + if (!successPath) { + throw new Error(s__('SecurityConfiguration|SAST merge request creation mutation failed')); + } + + redirectTo(successPath); + } catch (e) { + this.$emit('error', e.message); + this.isLoading = false; + } + }, + }, +}; +</script> + +<template> + <gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{ + s__('SecurityConfiguration|Configure via Merge Request') + }}</gl-button> +</template> diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue new file mode 100644 index 00000000000..166ee4ff194 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/upgrade.vue @@ -0,0 +1,26 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { UPGRADE_CTA } from './features_constants'; + +export default { + components: { + GlLink, + GlSprintf, + }, + i18n: { + UPGRADE_CTA, + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="$options.i18n.UPGRADE_CTA"> + <template #link="{ content }"> + <gl-link target="_blank" href="https://about.gitlab.com/pricing/"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/security_configuration/graphql/configure_sast.mutation.graphql b/app/assets/javascripts/security_configuration/graphql/configure_sast.mutation.graphql new file mode 100644 index 00000000000..9e826cf9e4b --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/configure_sast.mutation.graphql @@ -0,0 +1,6 @@ +mutation configureSast($input: ConfigureSastInput!) { + configureSast(input: $input) { + successPath + errors + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js new file mode 100644 index 00000000000..c98fa46b32b --- /dev/null +++ b/app/assets/javascripts/security_configuration/index.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import SecurityConfigurationApp from './components/app.vue'; + +export const initStaticSecurityConfiguration = (el) => { + if (!el) { + return null; + } + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + const { projectPath } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + projectPath, + }, + render(createElement) { + return createElement(SecurityConfigurationApp); + }, + }); +}; diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index be5fd93f77c..cbd68f2513a 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,11 +1,9 @@ <script> -// NOTE! For the first iteration, we are simply copying the implementation of Assignees -// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736 import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; -const DEFAULT_RENDER_COUNT = 5; +const LOADING_STATE = 'loading'; +const SUCCESS_STATE = 'success'; export default { components: { @@ -34,35 +32,21 @@ export default { data() { return { showLess: true, - loading: false, - requestedReviewSuccess: false, + loadingStates: {}, }; }, - computed: { - firstUser() { - return this.users[0]; - }, - hasOneUser() { - return this.users.length === 1; - }, - hiddenReviewersLabel() { - const { numberOfHiddenReviewers } = this; - return sprintf(__('+ %{numberOfHiddenReviewers} more'), { numberOfHiddenReviewers }); - }, - renderShowMoreSection() { - return this.users.length > DEFAULT_RENDER_COUNT; - }, - numberOfHiddenReviewers() { - return this.users.length - DEFAULT_RENDER_COUNT; - }, - uncollapsedUsers() { - const uncollapsedLength = this.showLess - ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) - : this.users.length; - return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users; - }, - username() { - return `@${this.firstUser.username}`; + watch: { + users: { + handler(users) { + this.loadingStates = users.reduce( + (acc, user) => ({ + ...acc, + [user.id]: acc[user.id] || null, + }), + this.loadingStates, + ); + }, + immediate: true, }, }, methods: { @@ -70,21 +54,23 @@ export default { this.showLess = !this.showLess; }, reRequestReview(userId) { - this.loading = true; + this.loadingStates[userId] = LOADING_STATE; this.$emit('request-review', { userId, callback: this.requestReviewComplete }); }, - requestReviewComplete(success) { + requestReviewComplete(userId, success) { if (success) { - this.requestedReviewSuccess = true; + this.loadingStates[userId] = SUCCESS_STATE; setTimeout(() => { - this.requestedReviewSuccess = false; + this.loadingStates[userId] = null; }, 1500); + } else { + this.loadingStates[userId] = null; } - - this.loading = false; }, }, + LOADING_STATE, + SUCCESS_STATE, }; </script> @@ -100,20 +86,22 @@ export default { <div class="gl-ml-3">@{{ user.username }}</div> </reviewer-avatar-link> <gl-icon - v-if="requestedReviewSuccess" + v-if="loadingStates[user.id] === $options.SUCCESS_STATE" :size="24" name="check" class="float-right gl-text-green-500" + data-testid="re-request-success" /> <gl-button v-else-if="user.can_update_merge_request && user.reviewed" v-gl-tooltip.left :title="__('Re-request review')" - :loading="loading" + :loading="loadingStates[user.id] === $options.LOADING_STATE" class="float-right gl-text-gray-500!" size="small" icon="redo" variant="link" + data-testid="re-request-button" @click="reRequestReview(user.id)" /> </div> diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index b23788f81fe..c4fe1be83fc 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -58,9 +58,9 @@ export default class SidebarMediator { .then(() => { this.store.updateReviewer(userId); toast(__('Requested review')); - callback(true); + callback(userId, true); }) - .catch(() => callback(false)); + .catch(() => callback(userId, false)); } setMoveToProjectId(projectId) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 92223c9058e..f430bf80cbb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -56,7 +56,10 @@ export default { mergeRequestsFfOnlyEnabled: data.project.mergeRequestsFfOnlyEnabled, onlyAllowMergeIfPipelineSucceeds: data.project.onlyAllowMergeIfPipelineSucceeds, }; - this.removeSourceBranch = data.project.mergeRequest.shouldRemoveSourceBranch; + this.removeSourceBranch = + data.project.mergeRequest.shouldRemoveSourceBranch || + data.project.mergeRequest.forceRemoveSourceBranch || + false; this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage; this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge; this.isSquashReadOnly = data.project.squashReadOnly; diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index 9479ef3cf79..8ee45b05431 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -5,6 +5,7 @@ fragment ReadyToMerge on Project { mergeRequest(iid: $iid) { autoMergeEnabled shouldRemoveSourceBranch + forceRemoveSourceBranch defaultMergeCommitMessage defaultMergeCommitMessageWithDescription defaultSquashCommitMessage diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index dd591f7bba3..aac5a5c1def 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -17,7 +17,13 @@ export const REPORT_FILE_TYPES = { * Security scan report types, as provided by the backend. */ export const REPORT_TYPE_SAST = 'sast'; +export const REPORT_TYPE_DAST = 'dast'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; +export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning'; +export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning'; +export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing'; +export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_compliance'; +export const REPORT_TYPE_API_FUZZING = 'api_fuzzing'; /** * SecurityReportTypeEnum values for use with GraphQL. diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 2cef43f19ab..036d95622ef 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -243,7 +243,8 @@ module NotesActions :type, :note, :line_code, # LegacyDiffNote - :position # DiffNote + :position, # DiffNote + :confidential ).tap do |create_params| create_params.merge!( params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id) diff --git a/app/controllers/concerns/redis_tracking.rb b/app/controllers/concerns/redis_tracking.rb index d71935356b8..a7e75f802a8 100644 --- a/app/controllers/concerns/redis_tracking.rb +++ b/app/controllers/concerns/redis_tracking.rb @@ -7,30 +7,26 @@ # # include RedisTracking # -# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score', feature: :my_feature -# -# if the feature flag is enabled by default you should use -# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score', feature: :my_feature, feature_default_enabled: true +# track_redis_hll_event :index, :show, name: 'i_analytics_dev_ops_score' # # You can also pass custom conditions using `if:`, using the same format as with Rails callbacks. module RedisTracking extend ActiveSupport::Concern class_methods do - def track_redis_hll_event(*controller_actions, name:, feature:, feature_default_enabled: false, if: nil) + def track_redis_hll_event(*controller_actions, name:, if: nil) custom_conditions = Array.wrap(binding.local_variable_get('if')) conditions = [:trackable_request?, *custom_conditions] after_action only: controller_actions, if: conditions do - track_unique_redis_hll_event(name, feature, feature_default_enabled) + track_unique_redis_hll_event(name) end end end private - def track_unique_redis_hll_event(event_name, feature, feature_default_enabled) - return unless metric_feature_enabled?(feature, feature_default_enabled) + def track_unique_redis_hll_event(event_name) return unless visitor_id Gitlab::UsageDataCounters::HLLRedisCounter.track_event(event_name, values: visitor_id) @@ -40,10 +36,6 @@ module RedisTracking request.format.html? && request.headers['DNT'] != '1' end - def metric_feature_enabled?(feature, default_enabled) - Feature.enabled?(feature, default_enabled: default_enabled) - end - def visitor_id return cookies[:visitor_id] if cookies[:visitor_id].present? return unless current_user diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index c93e75b438b..0ee8d0c9307 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -15,7 +15,7 @@ module SnippetsActions skip_before_action :verify_authenticity_token, if: -> { action_name == 'show' && js_request? } - track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: true + track_redis_hll_event :show, name: 'i_snippets_show' respond_to :html end diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 1ae90edd8f7..4014e4f0024 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -36,8 +36,7 @@ module WikiActions # NOTE: We want to include wiki page views in the same counter as the other # Event-based wiki actions tracked through TrackUniqueEvents, so we use the same event name. - track_redis_hll_event :show, name: Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION.to_s, - feature: :track_unique_wiki_page_views, feature_default_enabled: true + track_redis_hll_event :show, name: Gitlab::UsageDataCounters::TrackUniqueEvents::WIKI_ACTION.to_s helper_method :view_file_button, :diff_file_html_data diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 8c66f45dd79..3bb00978aac 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -35,7 +35,7 @@ class Projects::BlobController < Projects::ApplicationController record_experiment_user(:ci_syntax_templates, namespace_id: @project.namespace_id) if params[:file_name] == @project.ci_config_path_or_default end - track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true + track_redis_hll_event :create, :update, name: 'g_edit_by_sfe' feature_category :source_code_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index ff8c790f43d..c8bdbe548c8 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project) push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled) + push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) record_experiment_user(:invite_members_version_a) record_experiment_user(:invite_members_version_b) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 74936abe59c..59b14bbb91d 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -9,6 +9,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :set_pipeline_path, only: [:show] before_action :authorize_read_pipeline! before_action :authorize_read_build!, only: [:index] + before_action :authorize_read_analytics!, only: [:charts] before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 40e6590d85c..820b00a902e 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -5,7 +5,7 @@ class SearchController < ApplicationController include SearchHelper include RedisTracking - track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true + track_redis_hll_event :show, name: 'i_search_total' around_action :allow_gitaly_ref_name_caching diff --git a/app/graphql/queries/container_registry/get_container_repositories.query.graphql b/app/graphql/queries/container_registry/get_container_repositories.query.graphql index 6171233c446..4683ef9dfdb 100644 --- a/app/graphql/queries/container_registry/get_container_repositories.query.graphql +++ b/app/graphql/queries/container_registry/get_container_repositories.query.graphql @@ -6,11 +6,19 @@ query getProjectContainerRepositories( $after: String $before: String $isGroupPage: Boolean! + $sort: ContainerRepositorySort ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { __typename containerRepositoriesCount - containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + containerRepositories( + name: $name + after: $after + before: $before + first: $first + last: $last + sort: $sort + ) { __typename nodes { id @@ -35,7 +43,14 @@ query getProjectContainerRepositories( group(fullPath: $fullPath) @include(if: $isGroupPage) { __typename containerRepositoriesCount - containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + containerRepositories( + name: $name + after: $after + before: $before + first: $first + last: $last + sort: $sort + ) { __typename nodes { id diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 8e32661d4cb..0cefc84633d 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -12,6 +12,9 @@ module Types field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the user.' + field :bot, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if the user is a bot.', + method: :bot? field :username, GraphQL::STRING_TYPE, null: false, description: 'Username of the user. Unique within this instance of GitLab.' field :name, GraphQL::STRING_TYPE, null: false, diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 58aaadd5d49..3be107ea2e1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -16,6 +16,7 @@ module Ci include ShaAttribute include FromUnion include UpdatedAtFilterable + include EachBatch MAX_OPEN_MERGE_REQUESTS_REFS = 4 diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index e0c2b308247..2f0fd0af63b 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -55,6 +55,7 @@ class CommitStatus < ApplicationRecord scope :for_ids, -> (ids) { where(id: ids) } scope :for_ref, -> (ref) { where(ref: ref) } scope :by_name, -> (name) { where(name: name) } + scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } scope :for_project_paths, -> (paths) do where(project: Project.where_full_path_in(Array(paths))) diff --git a/app/models/member.rb b/app/models/member.rb index 2e79b50d6b7..62fe757683f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -47,6 +47,19 @@ class Member < ApplicationRecord }, if: :project_bot? + scope :in_hierarchy, ->(source) do + groups = source.root_ancestor.self_and_descendants + group_members = Member.default_scoped.where(source: groups) + + projects = source.root_ancestor.all_projects + project_members = Member.default_scoped.where(source: projects) + + Member.default_scoped.from_union([ + group_members, + project_members + ]).merge(self) + end + # This scope encapsulates (most of) the conditions a row in the member table # must satisfy if it is a valid permission. Of particular note: # @@ -79,12 +92,18 @@ class Member < ApplicationRecord scope :invite, -> { where.not(invite_token: nil) } scope :non_invite, -> { where(invite_token: nil) } + scope :request, -> { where.not(requested_at: nil) } scope :non_request, -> { where(requested_at: nil) } scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + + scope :created_today, -> do + now = Date.current + where(created_at: now.beginning_of_day..now.end_of_day) + end scope :last_ten_days_excluding_today, -> (today = Date.current) { where(created_at: (today - 10).beginning_of_day..(today - 1).end_of_day) } scope :has_access, -> { active.where('access_level > 0') } diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index d0e62a1afba..ab043227832 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -183,7 +183,17 @@ class PrometheusService < MonitoringService manual_configuration? && google_iap_audience_client_id.present? && google_iap_service_account_json.present? end + def clean_google_iap_service_account + return unless google_iap_service_account_json + + google_iap_service_account_json + .then { |json| Gitlab::Json.parse(json) } + .except('token_credential_uri') + end + def iap_client - @iap_client ||= Google::Auth::Credentials.new(Gitlab::Json.parse(google_iap_service_account_json), target_audience: google_iap_audience_client_id).client + @iap_client ||= Google::Auth::Credentials + .new(clean_google_iap_service_account, target_audience: google_iap_audience_client_id) + .client end end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 7605ef54d5b..8c3dcaa7c0f 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -31,6 +31,7 @@ class ProjectStatistics < ApplicationRecord scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) } scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) } + scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) } def total_repository_size repository_size + lfs_objects_size diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index f97d94c14f0..aaf985d6c63 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -221,6 +221,7 @@ class ProjectPolicy < BasePolicy enable :read_pages_content enable :read_release enable :read_analytics + enable :read_insights end # These abilities are not allowed to admins that are not members of the project, @@ -450,6 +451,9 @@ class ProjectPolicy < BasePolicy rule { analytics_disabled }.policy do prevent(:read_analytics) + prevent(:read_insights) + prevent(:read_cycle_analytics) + prevent(:read_repository_graphs) end rule { wiki_disabled }.policy do @@ -523,6 +527,7 @@ class ProjectPolicy < BasePolicy enable :read_cycle_analytics enable :read_pages_content enable :read_analytics + enable :read_insights # NOTE: may be overridden by IssuePolicy enable :read_issue diff --git a/app/services/ci/abort_project_pipelines_service.rb b/app/services/ci/abort_project_pipelines_service.rb new file mode 100644 index 00000000000..0b2fa9ed3c0 --- /dev/null +++ b/app/services/ci/abort_project_pipelines_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ci + class AbortProjectPipelinesService + # Danger: Cancels in bulk without callbacks + # Only for pipeline abandonment scenarios (current example: project delete) + def execute(project) + return unless Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml) + + pipelines = project.all_pipelines.cancelable + bulk_abort!(pipelines, status: :canceled) + + ServiceResponse.success(message: 'Pipelines canceled') + end + + private + + def bulk_abort!(pipelines, status:) + pipelines.each_batch do |pipeline_batch| + CommitStatus.in_pipelines(pipeline_batch).in_batches.update_all(status: status) # rubocop: disable Cop/InBatches + pipeline_batch.update_all(status: status) + end + end + end +end diff --git a/app/services/ci/cancel_user_pipelines_service.rb b/app/services/ci/cancel_user_pipelines_service.rb index 3a8b5e91088..3d3a8032e8e 100644 --- a/app/services/ci/cancel_user_pipelines_service.rb +++ b/app/services/ci/cancel_user_pipelines_service.rb @@ -6,6 +6,7 @@ module Ci # This is a bug with CodeReuse/ActiveRecord cop # https://gitlab.com/gitlab-org/gitlab/issues/32332 def execute(user) + # TODO: fix N+1 queries https://gitlab.com/gitlab-org/gitlab/-/issues/300685 user.pipelines.cancelable.find_each(&:cancel_running) ServiceResponse.success(message: 'Pipeline canceled') diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb index da653e2524a..c26d2e7ab47 100644 --- a/app/services/design_management/save_designs_service.rb +++ b/app/services/design_management/save_designs_service.rb @@ -18,7 +18,6 @@ module DesignManagement return error("Only #{MAX_FILES} files are allowed simultaneously") if files.size > MAX_FILES return error("Duplicate filenames are not allowed!") if files.map(&:original_filename).uniq.length != files.length return error("Design copy is in progress") if design_collection.copy_in_progress? - return error("Filenames contained invalid characters and could not be saved") if files.any?(&:filename_sanitized?) uploaded_designs, version = upload_designs! skipped_designs = designs - uploaded_designs diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 5fcf2d711b0..cffccda1a44 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -2,12 +2,12 @@ module Members class CreateService < Members::BaseService + include Gitlab::Utils::StrongMemoize + DEFAULT_LIMIT = 100 def execute(source) - return error(s_('AddMember|No users specified.')) if params[:user_ids].blank? - - user_ids = params[:user_ids].split(',').uniq.flatten + return error(s_('AddMember|No users specified.')) if user_ids.blank? return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if user_limit && user_ids.size > user_limit @@ -47,6 +47,13 @@ module Members private + def user_ids + strong_memoize(:user_ids) do + ids = params[:user_ids] || '' + ids.split(',').uniq.flatten + end + end + def user_limit limit = params.fetch(:limit, DEFAULT_LIMIT) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index bec75657530..c1501625300 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -21,11 +21,14 @@ module Projects def execute return false unless can?(current_user, :remove_project, project) + project.update_attribute(:pending_delete, true) # Flush the cache for both repositories. This has to be done _before_ # removing the physical repositories as some expiration code depends on # Git data (e.g. a list of branch names). flush_caches(project) + ::Ci::AbortProjectPipelinesService.new.execute(project) + Projects::UnlinkForkService.new(project, current_user).execute attempt_destroy(project) diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml index 4f4b6c1089c..899e58050af 100644 --- a/app/views/groups/registry/repositories/index.html.haml +++ b/app/views/groups/registry/repositories/index.html.haml @@ -1,6 +1,6 @@ - page_title _("Container Registry") - @content_class = "limit-container-width" unless fluid_layout -- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true} ) +- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true, sort: nil} ) %section #js-container-registry{ data: { endpoint: group_container_registries_path(@group), diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml deleted file mode 100644 index 9ad87518b1e..00000000000 --- a/app/views/help/_shortcuts.html.haml +++ /dev/null @@ -1,353 +0,0 @@ -#modal-shortcuts.modal{ tabindex: -1 } - .modal-dialog.modal-lg.modal-1040 - .modal-content - .modal-header - .js-toggle-shortcuts - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × - .modal-body - .row - .col-lg-4 - %table.shortcut-mappings.text-2 - %tbody - %tr - %th - %th= _('Global Shortcuts') - %tr - %td.shortcut - %kbd ? - %td= _('Toggle this dialog') - %tr - %td.shortcut - %kbd shift p - %td= _('Go to your projects') - %tr - %td.shortcut - %kbd shift g - %td= _('Go to your groups') - %tr - %td.shortcut - %kbd shift a - %td= _('Go to the activity feed') - %tr - %td.shortcut - %kbd shift l - %td= _('Go to the milestone list') - %tr - %td.shortcut - %kbd shift s - %td= _('Go to your snippets') - %tr - %td.shortcut - %kbd s - \/ - %kbd / - %td= _('Start search') - %tr - %td.shortcut - %kbd shift i - %td= _('Go to your issues') - %tr - %td.shortcut - %kbd shift m - %td= _('Go to your merge requests') - %tr - %td.shortcut - %kbd shift t - %td= _('Go to your To-Do list') - %tr - %td.shortcut - %kbd p - %kbd b - %td= _('Toggle the Performance Bar') - - if Gitlab.com? - %tr - %td.shortcut - %kbd g - %kbd x - %td= _('Toggle GitLab Next') - %tbody - %tr - %th - %th= _('Editing') - %tr - %td.shortcut - - if browser.platform.mac? - %kbd ⌘ shift p - - else - %kbd ctrl shift p - %td= _('Toggle Markdown preview') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-up', size: 12) - %td= _('Edit your most recent comment in a thread (from an empty textarea)') - %tbody - %tr - %th - %th= _('Wiki') - %tr - %td.shortcut - %kbd e - %td= _('Edit wiki page') - %tbody - %tr - %th - %th= _('Repository Graph') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-left', size: 12) - \/ - %kbd h - %td= _('Scroll left') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-right', size: 12) - \/ - %kbd l - %td= _('Scroll right') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-up', size: 12) - \/ - %kbd k - %td= _('Scroll up') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-down', size: 12) - \/ - %kbd j - %td= _('Scroll down') - %tr - %td.shortcut - %kbd - shift - = sprite_icon('arrow-up', size: 12) - \/ k - %td= _('Scroll to top') - %tr - %td.shortcut - %kbd - shift - = sprite_icon('arrow-down', size: 12) - \/ j - %td= _('Scroll to bottom') - .col-lg-4 - %table.shortcut-mappings.text-2 - %tbody - %tr - %th - %th= _('Project') - %tr - %td.shortcut - %kbd g - %kbd p - %td= _('Go to the project\'s overview page') - %tr - %td.shortcut - %kbd g - %kbd v - %td= _('Go to the project\'s activity feed') - %tr - %td.shortcut - %kbd g - %kbd r - %td= _('Go to releases') - %tr - %td.shortcut - %kbd g - %kbd f - %td= _('Go to files') - %tr - %td.shortcut - %kbd t - %td= _('Go to find file') - %tr - %td.shortcut - %kbd g - %kbd c - %td= _('Go to commits') - %tr - %td.shortcut - %kbd g - %kbd n - %td= _('Go to repository graph') - %tr - %td.shortcut - %kbd g - %kbd d - %td= _('Go to repository charts') - %tr - %td.shortcut - %kbd g - %kbd i - %td= _('Go to issues') - %tr - %td.shortcut - %kbd i - %td= _('New issue') - %tr - %td.shortcut - %kbd g - %kbd b - %td= _('Go to issue boards') - %tr - %td.shortcut - %kbd g - %kbd m - %td= _('Go to merge requests') - %tr - %td.shortcut - %kbd g - %kbd j - %td= _('Go to jobs') - %tr - %td.shortcut - %kbd g - %kbd l - %td= _('Go to metrics') - %tr - %td.shortcut - %kbd g - %kbd e - %td= _('Go to environments') - %tr - %td.shortcut - %kbd g - %kbd k - %td= _('Go to kubernetes') - %tr - %td.shortcut - %kbd g - %kbd s - %td= _('Go to snippets') - %tr - %td.shortcut - %kbd g - %kbd w - %td= _('Go to wiki') - %tbody - %tr - %th - %th= _('Project Files') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-up', size: 12) - %td= _('Move selection up') - %tr - %td.shortcut - %kbd - = sprite_icon('arrow-down', size: 12) - %td= _('Move selection down') - %tr - %td.shortcut - %kbd enter - %td= _('Open Selection') - %tr - %td.shortcut - %kbd esc - %td= _('Go back (while searching for files)') - %tr - %td.shortcut - %kbd y - %td= _('Go to file permalink (while viewing a file)') - .col-lg-4 - %table.shortcut-mappings.text-2 - %tbody - %tr - %th - %th= _('Epics, Issues, and Merge Requests') - %tr - %td.shortcut - %kbd r - %td= _('Comment/Reply (quoting selected text)') - %tr - %td.shortcut - %kbd e - %td= _('Edit description') - %tr - %td.shortcut - %kbd l - %td= _('Change label') - %tbody - %tr - %th - %th= _('Issues and Merge Requests') - %tr - %td.shortcut - %kbd a - %td= _('Change assignee') - %tr - %td.shortcut - %kbd m - %td= _('Change milestone') - %tbody - %tr - %th - %th= _('Merge Requests') - %tr - %td.shortcut - %kbd ] - \/ - %kbd j - %td= _('Next file in diff') - %tr - %td.shortcut - %kbd [ - \/ - %kbd k - %td= _('Previous file in diff') - %tr - %td.shortcut - - if browser.platform.mac? - %kbd ⌘ p - - else - %kbd ctrl p - %td= _('Go to file') - %tr - %td.shortcut - %kbd n - %td= _('Next unresolved discussion') - %tr - %td.shortcut - %kbd p - %td= _('Previous unresolved discussion') - %tr - %td.shortcut - %kbd b - %td= _('Copy source branch name') - %tbody - %tr - %th - %th= _('Merge Request Commits') - %tr - %td.shortcut - %kbd c - %td= _('Next commit') - %tr - %td.shortcut - %kbd x - %td= _('Previous commit') - %tbody - %tr - %th - %th= _('Web IDE') - %tr - %td.shortcut - - if browser.platform.mac? - %kbd ⌘ p - - else - %kbd ctrl p - %td= _('Go to file') - %tr - %td.shortcut - - if browser.platform.mac? - %kbd ⌘ enter - - else - %kbd ctrl enter - %td= _('Commit (when editing commit message)') diff --git a/app/views/help/shortcuts.js.haml b/app/views/help/shortcuts.js.haml deleted file mode 100644 index 99ed042ea3b..00000000000 --- a/app/views/help/shortcuts.js.haml +++ /dev/null @@ -1,3 +0,0 @@ -:plain - $("body").append("#{escape_javascript(render('shortcuts'))}"); - $("#modal-shortcuts").modal(); diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 8004a5facd7..7c896cd71ef 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -28,7 +28,7 @@ = _('GPG Key ID:') %span.monospace= signature.gpg_key_primary_keyid - = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') + = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link gl-display-block') %a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = label diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 93e94928110..a2009b96c0d 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,6 +1,6 @@ - page_title _("Container Registry") - @content_class = "limit-container-width" unless fluid_layout -- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false} ) +- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false, sort: nil} ) %section #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), diff --git a/app/views/projects/security/configuration/show.html.haml b/app/views/projects/security/configuration/show.html.haml index 1a371955be8..fe47ce327c2 100644 --- a/app/views/projects/security/configuration/show.html.haml +++ b/app/views/projects/security/configuration/show.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title _("Security Configuration") - page_title _("Security Configuration") -#js-security-configuration-static +#js-security-configuration-static{ data: {project_path: @project.full_path} } diff --git a/changelogs/unreleased/207473-allow-set-confidential-note-attribute.yml b/changelogs/unreleased/207473-allow-set-confidential-note-attribute.yml new file mode 100644 index 00000000000..78eb6e32060 --- /dev/null +++ b/changelogs/unreleased/207473-allow-set-confidential-note-attribute.yml @@ -0,0 +1,5 @@ +--- +title: Support setting confidential note attribute in UI +merge_request: 52949 +author: Lee Tickett @leetickett +type: added diff --git a/changelogs/unreleased/267140-add-user-bot-gql.yml b/changelogs/unreleased/267140-add-user-bot-gql.yml new file mode 100644 index 00000000000..cf2f13116a9 --- /dev/null +++ b/changelogs/unreleased/267140-add-user-bot-gql.yml @@ -0,0 +1,5 @@ +--- +title: Add bot to User GraphQL Type +merge_request: 52933 +author: +type: added diff --git a/changelogs/unreleased/290302-update-the-default-sort-order-of-the-image-repository-list.yml b/changelogs/unreleased/290302-update-the-default-sort-order-of-the-image-repository-list.yml new file mode 100644 index 00000000000..7d68ada42ed --- /dev/null +++ b/changelogs/unreleased/290302-update-the-default-sort-order-of-the-image-repository-list.yml @@ -0,0 +1,5 @@ +--- +title: Add sort to container registry list page +merge_request: 53820 +author: +type: changed diff --git a/changelogs/unreleased/296754-followup-from-refactor-namespaceonboardingaction-model-to-onboardi.yml b/changelogs/unreleased/296754-followup-from-refactor-namespaceonboardingaction-model-to-onboardi.yml new file mode 100644 index 00000000000..226033c26ba --- /dev/null +++ b/changelogs/unreleased/296754-followup-from-refactor-namespaceonboardingaction-model-to-onboardi.yml @@ -0,0 +1,5 @@ +--- +title: Remove namespace_onboarding_actions table +merge_request: 53488 +author: +type: other diff --git a/changelogs/unreleased/297346-flaky-spec-in-ee-spec-features-burnup_charts_spec-rb-burnup-charts.yml b/changelogs/unreleased/297346-flaky-spec-in-ee-spec-features-burnup_charts_spec-rb-burnup-charts.yml new file mode 100644 index 00000000000..72e55f73bcc --- /dev/null +++ b/changelogs/unreleased/297346-flaky-spec-in-ee-spec-features-burnup_charts_spec-rb-burnup-charts.yml @@ -0,0 +1,5 @@ +--- +title: Fix charts sometimes being hidden on milestone page +merge_request: 52552 +author: +type: fixed diff --git a/changelogs/unreleased/kassio-bulkimports-import-group-membership.yml b/changelogs/unreleased/kassio-bulkimports-import-group-membership.yml new file mode 100644 index 00000000000..bf75f1f68af --- /dev/null +++ b/changelogs/unreleased/kassio-bulkimports-import-group-membership.yml @@ -0,0 +1,5 @@ +--- +title: 'BulkImports: Migrate Group Membership' +merge_request: 53083 +author: +type: added diff --git a/changelogs/unreleased/khanchi-designs-patch2.yml b/changelogs/unreleased/khanchi-designs-patch2.yml deleted file mode 100644 index 1baee07be88..00000000000 --- a/changelogs/unreleased/khanchi-designs-patch2.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 'Designs: return error if uploading filenames with special chars' -merge_request: 44136 -author: Sushil Khanchi @khanchi97 -type: fixed diff --git a/changelogs/unreleased/link-new-line-gpg.yml b/changelogs/unreleased/link-new-line-gpg.yml new file mode 100644 index 00000000000..7cc450b100e --- /dev/null +++ b/changelogs/unreleased/link-new-line-gpg.yml @@ -0,0 +1,5 @@ +--- +title: Show helper link on a new line in GPG status popover +merge_request: 52894 +author: Yogi (@yo) +type: changed diff --git a/changelogs/unreleased/mc-backstage-reduce-db-updates-ci-minute-reset.yml b/changelogs/unreleased/mc-backstage-reduce-db-updates-ci-minute-reset.yml new file mode 100644 index 00000000000..7770e9a597f --- /dev/null +++ b/changelogs/unreleased/mc-backstage-reduce-db-updates-ci-minute-reset.yml @@ -0,0 +1,5 @@ +--- +title: Reset CI minutes only for namespaces that used minutes. +merge_request: 53740 +author: +type: changed diff --git a/config/feature_flags/development/abort_deleted_project_pipelines.yml b/config/feature_flags/development/abort_deleted_project_pipelines.yml new file mode 100644 index 00000000000..f09cc9dd86b --- /dev/null +++ b/config/feature_flags/development/abort_deleted_project_pipelines.yml @@ -0,0 +1,8 @@ +--- +name: abort_deleted_project_pipelines +introduced_by_url: https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1220 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/301106 +milestone: '13.9' +type: development +group: group::continuous integration +default_enabled: true diff --git a/config/feature_flags/development/confidential_notes.yml b/config/feature_flags/development/confidential_notes.yml new file mode 100644 index 00000000000..4e9add5eb3c --- /dev/null +++ b/config/feature_flags/development/confidential_notes.yml @@ -0,0 +1,8 @@ +--- +name: confidential_notes +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52949 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/207474 +milestone: '13.9' +type: development +group: group::product planning +default_enabled: false diff --git a/config/known_invalid_graphql_queries.yml b/config/known_invalid_graphql_queries.yml index 3bd117b38e6..8c9ba5cb83a 100644 --- a/config/known_invalid_graphql_queries.yml +++ b/config/known_invalid_graphql_queries.yml @@ -3,3 +3,4 @@ filenames: - ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql - ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/api_fuzzing_ci_configuration.query.graphql - ee/app/assets/javascripts/on_demand_scans/graphql/dast_profile_update.mutation.graphql + - ee/app/assets/javascripts/security_configuration/api_fuzzing/graphql/create_api_fuzzing_configuration.mutation.graphql diff --git a/db/migrate/20201007033527_add_daily_invites_to_plan_limits.rb b/db/migrate/20201007033527_add_daily_invites_to_plan_limits.rb new file mode 100644 index 00000000000..8f0079cd639 --- /dev/null +++ b/db/migrate/20201007033527_add_daily_invites_to_plan_limits.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddDailyInvitesToPlanLimits < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column(:plan_limits, :daily_invites, :integer, default: 0, null: false) + end +end diff --git a/db/migrate/20201007033723_insert_daily_invites_plan_limits.rb b/db/migrate/20201007033723_insert_daily_invites_plan_limits.rb new file mode 100644 index 00000000000..dcdcbbb0964 --- /dev/null +++ b/db/migrate/20201007033723_insert_daily_invites_plan_limits.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class InsertDailyInvitesPlanLimits < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + return unless Gitlab.com? + + create_or_update_plan_limit('daily_invites', 'free', 20) + create_or_update_plan_limit('daily_invites', 'bronze', 0) + create_or_update_plan_limit('daily_invites', 'silver', 0) + create_or_update_plan_limit('daily_invites', 'gold', 0) + end + + def down + return unless Gitlab.com? + + create_or_update_plan_limit('daily_invites', 'free', 0) + create_or_update_plan_limit('daily_invites', 'bronze', 0) + create_or_update_plan_limit('daily_invites', 'silver', 0) + create_or_update_plan_limit('daily_invites', 'gold', 0) + end +end diff --git a/db/migrate/20210205143926_remove_namespace_id_foreign_key_on_namespace_onboarding_actions.rb b/db/migrate/20210205143926_remove_namespace_id_foreign_key_on_namespace_onboarding_actions.rb new file mode 100644 index 00000000000..6fe66430dd0 --- /dev/null +++ b/db/migrate/20210205143926_remove_namespace_id_foreign_key_on_namespace_onboarding_actions.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemoveNamespaceIdForeignKeyOnNamespaceOnboardingActions < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + remove_foreign_key :namespace_onboarding_actions, :namespaces + end + end + + def down + with_lock_retries do + add_foreign_key :namespace_onboarding_actions, :namespaces, on_delete: :cascade + end + end +end diff --git a/db/post_migrate/20210205144537_remove_namespace_onboarding_actions_table.rb b/db/post_migrate/20210205144537_remove_namespace_onboarding_actions_table.rb new file mode 100644 index 00000000000..210b1d7822c --- /dev/null +++ b/db/post_migrate/20210205144537_remove_namespace_onboarding_actions_table.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class RemoveNamespaceOnboardingActionsTable < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + drop_table :namespace_onboarding_actions + end + end + + def down + with_lock_retries do + create_table :namespace_onboarding_actions do |t| + t.references :namespace, index: true, null: false + t.datetime_with_timezone :created_at, null: false + t.integer :action, limit: 2, null: false + end + end + end +end diff --git a/db/schema_migrations/20201007033527 b/db/schema_migrations/20201007033527 new file mode 100644 index 00000000000..b2cedd57973 --- /dev/null +++ b/db/schema_migrations/20201007033527 @@ -0,0 +1 @@ +1200747265d5095a86250020786d6f1e9e50bc75328a71de497046807afa89d7
\ No newline at end of file diff --git a/db/schema_migrations/20201007033723 b/db/schema_migrations/20201007033723 new file mode 100644 index 00000000000..c874ae0475b --- /dev/null +++ b/db/schema_migrations/20201007033723 @@ -0,0 +1 @@ +febefead6f966960f6493d29add5f35fc4a1080b5118c5526502fa5fe1d29023
\ No newline at end of file diff --git a/db/schema_migrations/20210205143926 b/db/schema_migrations/20210205143926 new file mode 100644 index 00000000000..00a8c3528a7 --- /dev/null +++ b/db/schema_migrations/20210205143926 @@ -0,0 +1 @@ +cdf55e9f2b1b9c375920198a438d29fe3c9ab7147f3c670b0d66b11d499573d9
\ No newline at end of file diff --git a/db/schema_migrations/20210205144537 b/db/schema_migrations/20210205144537 new file mode 100644 index 00000000000..6ca27521248 --- /dev/null +++ b/db/schema_migrations/20210205144537 @@ -0,0 +1 @@ +d9cfb7515805e642c562b8be58b6cd482c24e62e76245db35a7d91b25c327d8d
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 5c1791e6264..3088c2d03aa 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14289,22 +14289,6 @@ CREATE TABLE namespace_limits ( temporary_storage_increase_ends_on date ); -CREATE TABLE namespace_onboarding_actions ( - id bigint NOT NULL, - namespace_id bigint NOT NULL, - created_at timestamp with time zone NOT NULL, - action smallint NOT NULL -); - -CREATE SEQUENCE namespace_onboarding_actions_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE namespace_onboarding_actions_id_seq OWNED BY namespace_onboarding_actions.id; - CREATE TABLE namespace_package_settings ( namespace_id bigint NOT NULL, maven_duplicates_allowed boolean DEFAULT true NOT NULL, @@ -15522,6 +15506,7 @@ CREATE TABLE plan_limits ( ci_max_artifact_size_api_fuzzing integer DEFAULT 0 NOT NULL, ci_pipeline_deployments integer DEFAULT 500 NOT NULL, pull_mirror_interval_seconds integer DEFAULT 300 NOT NULL, + daily_invites integer DEFAULT 0 NOT NULL, rubygems_max_file_size bigint DEFAULT '3221225472'::bigint NOT NULL ); @@ -19096,8 +19081,6 @@ ALTER TABLE ONLY metrics_users_starred_dashboards ALTER COLUMN id SET DEFAULT ne ALTER TABLE ONLY milestones ALTER COLUMN id SET DEFAULT nextval('milestones_id_seq'::regclass); -ALTER TABLE ONLY namespace_onboarding_actions ALTER COLUMN id SET DEFAULT nextval('namespace_onboarding_actions_id_seq'::regclass); - ALTER TABLE ONLY namespace_statistics ALTER COLUMN id SET DEFAULT nextval('namespace_statistics_id_seq'::regclass); ALTER TABLE ONLY namespaces ALTER COLUMN id SET DEFAULT nextval('namespaces_id_seq'::regclass); @@ -20442,9 +20425,6 @@ ALTER TABLE ONLY namespace_aggregation_schedules ALTER TABLE ONLY namespace_limits ADD CONSTRAINT namespace_limits_pkey PRIMARY KEY (namespace_id); -ALTER TABLE ONLY namespace_onboarding_actions - ADD CONSTRAINT namespace_onboarding_actions_pkey PRIMARY KEY (id); - ALTER TABLE ONLY namespace_package_settings ADD CONSTRAINT namespace_package_settings_pkey PRIMARY KEY (namespace_id); @@ -22619,8 +22599,6 @@ CREATE INDEX index_mr_metrics_on_target_project_id_merged_at_time_to_merge ON me CREATE UNIQUE INDEX index_namespace_aggregation_schedules_on_namespace_id ON namespace_aggregation_schedules USING btree (namespace_id); -CREATE INDEX index_namespace_onboarding_actions_on_namespace_id ON namespace_onboarding_actions USING btree (namespace_id); - CREATE UNIQUE INDEX index_namespace_root_storage_statistics_on_namespace_id ON namespace_root_storage_statistics USING btree (namespace_id); CREATE UNIQUE INDEX index_namespace_statistics_on_namespace_id ON namespace_statistics USING btree (namespace_id); @@ -25159,9 +25137,6 @@ ALTER TABLE ONLY merge_request_assignees ALTER TABLE ONLY packages_dependency_links ADD CONSTRAINT fk_rails_4437bf4070 FOREIGN KEY (dependency_id) REFERENCES packages_dependencies(id) ON DELETE CASCADE; -ALTER TABLE ONLY namespace_onboarding_actions - ADD CONSTRAINT fk_rails_4504f6875a FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; - ALTER TABLE ONLY project_auto_devops ADD CONSTRAINT fk_rails_45436b12b2 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md index 57af1166076..304dab7b010 100644 --- a/doc/administration/instance_limits.md +++ b/doc/administration/instance_limits.md @@ -96,6 +96,13 @@ Read more on the [Rack Attack initializer](../security/rack_attack.md) method of - **Default rate limit** - Disabled +### Member Invitations + +Limit the maximum daily member invitations allowed per group hierarchy. + +- GitLab.com: Free members may invite 20 members per day. +- Self-managed: Invites are not limited. + ## Gitaly concurrency limit Clone traffic can put a large strain on your Gitaly service. To prevent such workloads from overwhelming your Gitaly server, you can set concurrency limits in Gitaly’s configuration file. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index c88a6d0bff5..8880c26fe3c 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -27054,6 +27054,11 @@ type User { avatarUrl: String """ + Indicates if the user is a bot. + """ + bot: Boolean! + + """ User email. Deprecated in 13.7: Use public_email. """ email: String @deprecated(reason: "Use public_email. Deprecated in 13.7.") diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index d3a6ccc4198..4474f1700f5 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -77879,6 +77879,24 @@ "deprecationReason": null }, { + "name": "bot", + "description": "Indicates if the user is a bot.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "email", "description": "User email. Deprecated in 13.7: Use public_email.", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1190408f864..b6312e4c9a9 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -4031,6 +4031,7 @@ Autogenerated return type of UpdateSnippet. | `assignedMergeRequests` | MergeRequestConnection | Merge Requests assigned to the user. | | `authoredMergeRequests` | MergeRequestConnection | Merge Requests authored by the user. | | `avatarUrl` | String | URL of the user's avatar. | +| `bot` | Boolean! | Indicates if the user is a bot. | | `email` **{warning-solid}** | String | **Deprecated:** Use public_email. Deprecated in 13.7. | | `groupCount` | Int | Group count for the user. Available only when feature flag `user_group_counts` is enabled. | | `groupMemberships` | GroupMemberConnection | Group memberships of the user. | diff --git a/doc/api/issues.md b/doc/api/issues.md index ab8dc8f590d..c333967b36c 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -60,8 +60,8 @@ GET /issues?state=opened | `due_date` | string | no | Return issues that have no due date, are overdue, or whose due date is this week, this month, or between two weeks ago and next month. Accepts: `0` (no due date), `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`. _(Introduced in [GitLab 13.3](https://gitlab.com/gitlab-org/gitlab/-/issues/233420))_ | | `iids[]` | integer array | no | Return only the issues having the given `iid` | | `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` | -| `iteration_id` **(STARTER)** | integer | no | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.6)_ | -| `iteration_title` **(STARTER)** | string | no | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.6)_ | +| `iteration_id` **(PREMIUM)** | integer | no | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in GitLab 13.6)_ | +| `iteration_title` **(PREMIUM)** | string | no | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. _([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in GitLab 13.6)_ | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | diff --git a/doc/development/fe_guide/style/html.md b/doc/development/fe_guide/style/html.md index 7fedbc6ce0d..e53686de1a0 100644 --- a/doc/development/fe_guide/style/html.md +++ b/doc/development/fe_guide/style/html.md @@ -6,6 +6,38 @@ info: To determine the technical writer assigned to the Stage/Group associated w # HTML style guide +## Semantic elements + +[Semantic elements](https://developer.mozilla.org/en-US/docs/Glossary/Semantics) are HTML tags that +give semantic (rather than presentational) meaning to the data they contain. For example: + +- [`<article>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/article) +- [`<nav>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/nav) +- [`<strong>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong) + +Prefer using semantic tags, but only if the intention is truly accurate with the semantic meaning +of the tag itself. View the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element) +for a description on what each tag semantically means. + +```html +<!-- bad - could use semantic tags instead of div's. --> +<div class="..."> + <p> + <!-- bad - this isn't what "strong" is meant for. --> + Simply visit your <strong>Settings</strong> to say hello to the world. + </p> + <div class="...">...</div> +</div> + +<!-- good - prefer semantic classes used accurately --> +<section class="..."> + <p> + Simply visit your <span class="gl-font-weight-bold">Settings</span> to say hello to the world. + </p> + <footer class="...">...</footer> +</section> +``` + ## Buttons ### Button type diff --git a/doc/development/usage_ping.md b/doc/development/usage_ping.md index 4b081f59abd..752af29f594 100644 --- a/doc/development/usage_ping.md +++ b/doc/development/usage_ping.md @@ -495,18 +495,17 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF aggregation. - `aggregation`: may be set to a `:daily` or `:weekly` key. Defines how counting data is stored in Redis. Aggregation on a `daily` basis does not pull more fine grained data. - - `feature_flag`: optional. For details, see our [GitLab internal Feature flags](feature_flags/) documentation. The feature flags are owned by the group adding the event tracking. + - `feature_flag`: optional `default_enabled: :yaml`. If no feature flag is set then the tracking is enabled. For details, see our [GitLab internal Feature flags](feature_flags/) documentation. The feature flags are owned by the group adding the event tracking. Use one of the following methods to track events: -1. Track event in controller using `RedisTracking` module with `track_redis_hll_event(*controller_actions, name:, feature:, feature_default_enabled: false)`. +1. Track event in controller using `RedisTracking` module with `track_redis_hll_event(*controller_actions, name:, if: nil)`. Arguments: - `controller_actions`: controller actions we want to track. - `name`: event name. - - `feature`: feature name, all metrics we track should be under feature flag. - - `feature_default_enabled`: feature flag is disabled by default, set to `true` for it to be enabled by default. + - `if`: optional custom conditions, using the same format as with Rails callbacks. Example usage: @@ -516,7 +515,7 @@ Use one of the following methods to track events: include RedisTracking skip_before_action :authenticate_user!, only: :show - track_redis_hll_event :index, :show, name: 'g_compliance_example_feature_visitors', feature: :compliance_example_feature, feature_default_enabled: true + track_redis_hll_event :index, :show, name: 'g_compliance_example_feature_visitors' def index render html: 'index' diff --git a/doc/user/discussions/img/confidential_comments_v13_9.png b/doc/user/discussions/img/confidential_comments_v13_9.png Binary files differnew file mode 100644 index 00000000000..d3e13f37ae9 --- /dev/null +++ b/doc/user/discussions/img/confidential_comments_v13_9.png diff --git a/doc/user/discussions/index.md b/doc/user/discussions/index.md index 0f718cfdb8d..6268b525755 100644 --- a/doc/user/discussions/index.md +++ b/doc/user/discussions/index.md @@ -7,12 +7,12 @@ type: reference, howto # Threads **(FREE)** -The ability to contribute conversationally is offered throughout GitLab. +You can use words to communicate with other users all over GitLab. -You can leave a comment in the following places: +For example, you can leave a comment in the following places: - Issues -- Epics **(ULTIMATE)** +- Epics - Merge requests - Snippets - Commits @@ -281,6 +281,23 @@ edit existing comments. Non-team members are restricted from adding or editing c Additionally, locked issues and merge requests can not be reopened. +## Confidential Comments + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207473) in GitLab 13.9. +> - 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. **(FREE SELF)** + +WARNING: +This feature might not be available to you. Check the **version history** note above for details. + +When creating a comment, you can decide to make it visible only to the project members (users with Reporter and higher permissions). + +To create a confidential comment, select the **Make this comment confidential** checkbox before you submit it. + +![Confidential comments](img/confidential_comments_v13_9.png) + ## Merge Request Reviews > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/4213) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.4. @@ -418,25 +435,6 @@ the thread will be automatically resolved, and GitLab will create a new commit and push the suggested change directly into the codebase in the merge request's branch. [Developer permission](../permissions.md) is required to do so. -### Enable or disable Custom commit messages for suggestions **(FREE SELF)** - -Custom commit messages for suggestions is under development but ready for production use. It is -deployed behind a feature flag that is **enabled by default**. -[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) -can opt to disable it. - -To disable custom commit messages for suggestions: - -```ruby -Feature.disable(:suggestions_custom_commit) -``` - -To enable custom commit messages for suggestions: - -```ruby -Feature.enable(:suggestions_custom_commit) -``` - ### Multi-line Suggestions > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/53310) in GitLab 11.10. @@ -532,27 +530,6 @@ to your branch to address your reviewers' requests. ![A code change suggestion displayed, with the button to apply the batch of suggestions highlighted.](img/apply_batch_of_suggestions_v13_1.jpg "Apply a batch of suggestions") -#### Enable or disable Batch Suggestions **(FREE SELF)** - -Batch Suggestions is -deployed behind a feature flag that is **enabled by default**. -[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) -can opt to disable it for your instance. - -To enable it: - -```ruby -# Instance-wide -Feature.enable(:batch_suggestions) -``` - -To disable it: - -```ruby -# Instance-wide -Feature.disable(:batch_suggestions) -``` - ## Start a thread by replying to a standard comment > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/30299) in GitLab 11.9 @@ -585,3 +562,62 @@ In the comment, click the **More Actions** menu and click **Assign to commenting Click the button again to unassign the commenter. ![Assign to commenting user](img/quickly_assign_commenter_v13_1.png) + +## Enable or disable Confidential Comments **(FREE SELF)** + +Confidential Comments 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(:confidential_notes) +``` + +To disable it: + +```ruby +Feature.disable(:confidential_notes) +``` + +## Enable or disable Custom commit messages for suggestions **(FREE SELF)** + +Custom commit messages for suggestions is under development but ready for production use. It is +deployed behind a feature flag that is **enabled by default**. +[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) +can opt to disable it. + +To disable custom commit messages for suggestions: + +```ruby +Feature.disable(:suggestions_custom_commit) +``` + +To enable custom commit messages for suggestions: + +```ruby +Feature.enable(:suggestions_custom_commit) +``` + +## Enable or disable Batch Suggestions **(FREE SELF)** + +Batch Suggestions is +deployed behind a feature flag that is **enabled by default**. +[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) +can opt to disable it for your instance. + +To enable it: + +```ruby +# Instance-wide +Feature.enable(:batch_suggestions) +``` + +To disable it: + +```ruby +# Instance-wide +Feature.disable(:batch_suggestions) +``` diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 1109171f9e9..68a68ed65ad 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -183,6 +183,7 @@ The following table depicts the various user permission levels in a project. | Delete pipelines | | | | | ✓ | | Delete merge request | | | | | ✓ | | Disable notification emails | | | | | ✓ | +| Administer project compliance frameworks | | | | | ✓ | | Force push to protected branches (*4*) | | | | | | | Remove protected branches (*4*) | | | | | | @@ -293,6 +294,7 @@ group. | View Billing **(FREE SAAS)** | | | | | ✓ (4) | | View Usage Quotas **(FREE SAAS)** | | | | | ✓ (4) | | Filter members by 2FA status | | | | | ✓ | +| Administer project compliance frameworks | | | | | ✓ | 1. Groups can be set to [allow either Owners or Owners and Maintainers to create subgroups](group/subgroups/index.md#creating-a-subgroup) diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index d40c638105e..c307fd8d628 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -198,6 +198,8 @@ service account can be found at Google's documentation for Prometheus OAuth Client secured with Google IAP. 1. (Optional) In **Google IAP Service Account JSON**, provide the contents of the Service Account credentials file that is authorized to access the Prometheus resource. + The JSON key `token_credential_uri` is discarded to prevent + [Server-side Request Forgery (SSRF)](https://www.hackerone.com/blog-How-To-Server-Side-Request-Forgery-SSRF). 1. Click **Save changes**. ![Configure Prometheus Service](img/prometheus_manual_configuration_v13_2.png) diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index d8ecef61363..6f33a718191 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -46,17 +46,17 @@ Compliance framework labels do not affect your project settings. > - 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-custom-compliance-frameworks). **(PREMIUM ONLY)** +> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-custom-compliance-frameworks). **(PREMIUM)** WARNING: This feature might not be available to you. Check the **version history** note above for details. -GitLab 13.8 introduces custom compliance frameworks at the group-level. A group owner can create a compliance framework label +GitLab 13.9 introduces custom compliance frameworks at the group-level. A group owner can create a compliance framework label and assign it to any number of projects within that group or sub-groups. When this feature is enabled, projects can only be assigned compliance framework labels that already exist within that group. -If existing [Compliance frameworks](#compliance-framework) are not sufficient, you can now create -your own. +If existing [Compliance frameworks](#compliance-framework) are not sufficient, project and group owners +can now create their own. New compliance framework labels can be created and updated using GraphQL. @@ -320,7 +320,7 @@ Add the URL of a Jaeger server to allow your users to [easily access the Jaeger [Add Storage credentials](../../../operations/incident_management/status_page.md#sync-incidents-to-the-status-page) to enable the syncing of public Issues to a [deployed status page](../../../operations/incident_management/status_page.md#create-a-status-page-project). -### Enable or disable custom compliance frameworks **(PREMIUM ONLY)** +### Enable or disable custom compliance frameworks **(PREMIUM)** Enabling or disabling custom compliance frameworks is under development and not ready for production use. It is deployed behind a feature flag that is **disabled by default**. diff --git a/lib/api/lint.rb b/lib/api/lint.rb index f1f34622187..2d30754a36d 100644 --- a/lib/api/lint.rb +++ b/lib/api/lint.rb @@ -11,6 +11,8 @@ module API optional :include_merged_yaml, type: Boolean, desc: 'Whether or not to include merged CI config yaml in the response' end post '/lint' do + unauthorized! unless Gitlab::CurrentSettings.signup_enabled? && current_user + result = Gitlab::Ci::YamlProcessor.new(params[:content], user: current_user).execute status 200 @@ -55,7 +57,7 @@ module API optional :dry_run, type: Boolean, default: false, desc: 'Run pipeline creation simulation, or only do static check.' end post ':id/ci/lint' do - authorize! :download_code, user_project + authorize! :create_pipeline, user_project result = Gitlab::Ci::Lint .new(project: user_project, current_user: current_user) diff --git a/lib/api/merge_request_approvals.rb b/lib/api/merge_request_approvals.rb index 00f42703731..0cdfd8f94b4 100644 --- a/lib/api/merge_request_approvals.rb +++ b/lib/api/merge_request_approvals.rb @@ -26,6 +26,8 @@ module API # GET /projects/:id/merge_requests/:merge_request_iid/approvals desc 'List approvals for merge request' get 'approvals' do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present_approval(merge_request) diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb index 0ffb38438eb..97a6c7075b3 100644 --- a/lib/api/merge_request_diffs.rb +++ b/lib/api/merge_request_diffs.rb @@ -23,6 +23,8 @@ module API use :pagination end get ":id/merge_requests/:merge_request_iid/versions" do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present paginate(merge_request.merge_request_diffs.order_id_desc), with: Entities::MergeRequestDiff @@ -39,6 +41,8 @@ module API end get ":id/merge_requests/:merge_request_iid/versions/:version_id" do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request.merge_request_diffs.find(params[:version_id]), with: Entities::MergeRequestDiffFull diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index cff0866c65e..5051c1a5529 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -248,6 +248,8 @@ module API success Entities::MergeRequest end get ':id/merge_requests/:merge_request_iid' do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request, @@ -264,7 +266,10 @@ module API success Entities::UserBasic end get ':id/merge_requests/:merge_request_iid/participants' do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) + participants = ::Kaminari.paginate_array(merge_request.participants) present paginate(participants), with: Entities::UserBasic @@ -274,6 +279,8 @@ module API success Entities::Commit end get ':id/merge_requests/:merge_request_iid/commits' do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) commits = @@ -355,6 +362,8 @@ module API success Entities::MergeRequestChanges end get ':id/merge_requests/:merge_request_iid/changes' do + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + merge_request = find_merge_request_with_access(params[:merge_request_iid]) present merge_request, @@ -370,6 +379,8 @@ module API get ':id/merge_requests/:merge_request_iid/pipelines' do pipelines = merge_request_pipelines_with_access + not_found!("Merge Request") unless can?(current_user, :read_merge_request, user_project) + present paginate(pipelines), with: Entities::Ci::PipelineBasic end diff --git a/lib/api/todos.rb b/lib/api/todos.rb index 03850ba1c4e..afc1525cbe2 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -28,6 +28,11 @@ module API end post ":id/#{type}/:#{type_id_str}/todo" do issuable = instance_exec(params[type_id_str], &finder) + + unless can?(current_user, :read_merge_request, issuable.project) + not_found!(type.split("_").map(&:capitalize).join(" ")) + end + todo = TodoService.new.mark_todo(issuable, current_user).first if todo diff --git a/lib/bulk_imports/groups/graphql/get_members_query.rb b/lib/bulk_imports/groups/graphql/get_members_query.rb new file mode 100644 index 00000000000..1287abc85dc --- /dev/null +++ b/lib/bulk_imports/groups/graphql/get_members_query.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Graphql + module GetMembersQuery + extend self + def to_s + <<-'GRAPHQL' + query($full_path: ID!, $cursor: String) { + group(fullPath: $full_path) { + group_members: groupMembers(relations: DIRECT, first: 100, after: $cursor) { + page_info: pageInfo { + end_cursor: endCursor + has_next_page: hasNextPage + } + nodes { + created_at: createdAt + updated_at: updatedAt + expires_at: expiresAt + access_level: accessLevel { + integer_value: integerValue + } + user { + public_email: publicEmail + } + } + } + } + } + GRAPHQL + end + + def variables(entity) + { + full_path: entity.source_full_path, + cursor: entity.next_page_for(:group_members) + } + end + + def base_path + %w[data group group_members] + end + + def data_path + base_path << 'nodes' + end + + def page_info_path + base_path << 'page_info' + end + end + end + end +end diff --git a/lib/bulk_imports/groups/loaders/members_loader.rb b/lib/bulk_imports/groups/loaders/members_loader.rb new file mode 100644 index 00000000000..ccf44b31aee --- /dev/null +++ b/lib/bulk_imports/groups/loaders/members_loader.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Loaders + class MembersLoader + def initialize(*); end + + def load(context, data) + return unless data + + context.group.members.create!(data) + end + end + end + end +end diff --git a/lib/bulk_imports/groups/pipelines/members_pipeline.rb b/lib/bulk_imports/groups/pipelines/members_pipeline.rb new file mode 100644 index 00000000000..ddc2cb124db --- /dev/null +++ b/lib/bulk_imports/groups/pipelines/members_pipeline.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Pipelines + class MembersPipeline + include Pipeline + + extractor BulkImports::Common::Extractors::GraphqlExtractor, + query: BulkImports::Groups::Graphql::GetMembersQuery + + transformer Common::Transformers::ProhibitedAttributesTransformer + transformer BulkImports::Groups::Transformers::MemberAttributesTransformer + + loader BulkImports::Groups::Loaders::MembersLoader + + def after_run(context, extracted_data) + context.entity.update_tracker_for( + relation: :group_members, + has_next_page: extracted_data.has_next_page?, + next_page: extracted_data.next_page + ) + + if extracted_data.has_next_page? + run(context) + end + end + end + end + end +end diff --git a/lib/bulk_imports/groups/transformers/member_attributes_transformer.rb b/lib/bulk_imports/groups/transformers/member_attributes_transformer.rb new file mode 100644 index 00000000000..622f5b60ffe --- /dev/null +++ b/lib/bulk_imports/groups/transformers/member_attributes_transformer.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Transformers + class MemberAttributesTransformer + def initialize(*); end + + def transform(context, data) + data + .then { |data| add_user(data) } + .then { |data| add_access_level(data) } + .then { |data| add_author(data, context) } + end + + private + + def add_user(data) + user = find_user(data&.dig('user', 'public_email')) + + return unless user + + data + .except('user') + .merge('user_id' => user.id) + end + + def find_user(email) + return unless email + + User.find_by_any_email(email, confirmed: true) + end + + def add_access_level(data) + access_level = data&.dig('access_level', 'integer_value') + + return unless valid_access_level?(access_level) + + data.merge('access_level' => access_level) + end + + def valid_access_level?(access_level) + Gitlab::Access + .options_with_owner + .value?(access_level) + end + + def add_author(data, context) + return unless data + + data.merge('created_by_id' => context.current_user.id) + end + end + end + end +end diff --git a/lib/bulk_imports/importers/group_importer.rb b/lib/bulk_imports/importers/group_importer.rb index c01a4ec025d..4888734087f 100644 --- a/lib/bulk_imports/importers/group_importer.rb +++ b/lib/bulk_imports/importers/group_importer.rb @@ -23,6 +23,7 @@ module BulkImports [ BulkImports::Groups::Pipelines::GroupPipeline, BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, + BulkImports::Groups::Pipelines::MembersPipeline, BulkImports::Groups::Pipelines::LabelsPipeline ] end diff --git a/lib/gitlab/auth/otp/strategies/forti_token_cloud.rb b/lib/gitlab/auth/otp/strategies/forti_token_cloud.rb index d7506eca242..079d631e22a 100644 --- a/lib/gitlab/auth/otp/strategies/forti_token_cloud.rb +++ b/lib/gitlab/auth/otp/strategies/forti_token_cloud.rb @@ -61,8 +61,7 @@ module Gitlab headers: { 'Content-Type': 'application/json' }.merge(headers), - body: body, - verify: false # FTC API Docs specifically mentions to turn off SSL Verification while making requests. + body: body ) end end diff --git a/lib/gitlab/ci/pipeline/chain/validate/abilities.rb b/lib/gitlab/ci/pipeline/chain/validate/abilities.rb index e68d9020a21..55c125e03d5 100644 --- a/lib/gitlab/ci/pipeline/chain/validate/abilities.rb +++ b/lib/gitlab/ci/pipeline/chain/validate/abilities.rb @@ -10,6 +10,10 @@ module Gitlab include Chain::Helpers def perform! + if project.pending_delete? + return error('Project is deleted!') + end + unless project.builds_enabled? return error('Pipelines are disabled!') end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index fdf736f122d..a64bb08fe3a 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -13,7 +13,6 @@ module Gitlab gon.asset_host = ActionController::Base.asset_host gon.webpack_public_path = webpack_public_path gon.relative_url_root = Gitlab.config.gitlab.relative_url_root - gon.shortcuts_path = Gitlab::Routing.url_helpers.help_page_path('shortcuts') gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class if Gitlab.config.sentry.enabled diff --git a/lib/gitlab/tree_summary.rb b/lib/gitlab/tree_summary.rb index 9b67599668a..bc7b8bd2b94 100644 --- a/lib/gitlab/tree_summary.rb +++ b/lib/gitlab/tree_summary.rb @@ -40,21 +40,17 @@ module Gitlab # - An Array of the unique ::Commit objects in the first value def summarize summary = contents - .map { |content| build_entry(content) } .tap { |summary| fill_last_commits!(summary) } [summary, commits] end def fetch_logs - cache_key = ['projects', project.id, 'logs', commit.id, path, offset] - Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRE_IN) do - logs, _ = summarize + logs, _ = summarize - new_offset = next_offset if more? + new_offset = next_offset if more? - [logs.as_json, new_offset] - end + [logs.as_json, new_offset] end # Does the tree contain more entries after the given offset + limit? @@ -71,7 +67,7 @@ module Gitlab private def contents - all_contents[offset, limit] + all_contents[offset, limit] || [] end def commits @@ -82,22 +78,17 @@ module Gitlab project.repository end - def entry_path(entry) - File.join(*[path, entry[:file_name]].compact).force_encoding(Encoding::ASCII_8BIT) + # Ensure the path is in "path/" format + def ensured_path + File.join(*[path, ""]) if path end - def build_entry(entry) - { file_name: entry.name, type: entry.type } + def entry_path(entry) + File.join(*[path, entry[:file_name]].compact).force_encoding(Encoding::ASCII_8BIT) end def fill_last_commits!(entries) - # Ensure the path is in "path/" format - ensured_path = - if path - File.join(*[path, ""]) - end - - commits_hsh = repository.list_last_commits_for_tree(commit.id, ensured_path, offset: offset, limit: limit, literal_pathspec: true) + commits_hsh = fetch_last_cached_commits_list prerender_commit_full_titles!(commits_hsh.values) entries.each do |entry| @@ -112,6 +103,18 @@ module Gitlab end end + def fetch_last_cached_commits_list + cache_key = ['projects', project.id, 'last_commits_list', commit.id, ensured_path, offset, limit] + + commits = Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRE_IN) do + repository + .list_last_commits_for_tree(commit.id, ensured_path, offset: offset, limit: limit, literal_pathspec: true) + .transform_values!(&:to_hash) + end + + commits.transform_values! { |value| Commit.from_hash(value, project) } + end + def cache_commit(commit) return unless commit.present? @@ -123,12 +126,18 @@ module Gitlab end def all_contents - strong_memoize(:all_contents) do + strong_memoize(:all_contents) { cached_contents } + end + + def cached_contents + cache_key = ['projects', project.id, 'content', commit.id, path] + + Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRE_IN) do [ *tree.trees, *tree.blobs, *tree.submodules - ] + ].map { |entry| { file_name: entry.name, type: entry.type } } end end diff --git a/lib/gitlab/usage/metrics/aggregates/aggregate.rb b/lib/gitlab/usage/metrics/aggregates/aggregate.rb index d8667a0a58f..1fc40798320 100644 --- a/lib/gitlab/usage/metrics/aggregates/aggregate.rb +++ b/lib/gitlab/usage/metrics/aggregates/aggregate.rb @@ -148,7 +148,7 @@ module Gitlab end def load_yaml_from_path(path) - YAML.safe_load(File.read(path))&.map(&:with_indifferent_access) + YAML.safe_load(File.read(path), aliases: true)&.map(&:with_indifferent_access) end end end diff --git a/lib/gitlab/usage_data_counters/hll_redis_counter.rb b/lib/gitlab/usage_data_counters/hll_redis_counter.rb index ed2ce2cecb0..68ae239debb 100644 --- a/lib/gitlab/usage_data_counters/hll_redis_counter.rb +++ b/lib/gitlab/usage_data_counters/hll_redis_counter.rb @@ -129,6 +129,8 @@ module Gitlab event = event_for(event_name) raise UnknownEvent, "Unknown event #{event_name}" unless event.present? + return unless feature_enabled?(event) + Gitlab::Redis::HLL.add(key: redis_key(event, time, context), value: values, expiry: expiry(event)) end @@ -148,6 +150,12 @@ module Gitlab redis_usage_data { Gitlab::Redis::HLL.count(keys: keys) } end + def feature_enabled?(event) + return true if event[:feature_flag].blank? + + Feature.enabled?(event[:feature_flag], default_enabled: :yaml) + end + # Allow to add totals for events that are in the same redis slot, category and have the same aggregation level # and if there are more than 1 event def eligible_for_totals?(events_names) diff --git a/lib/uploaded_file.rb b/lib/uploaded_file.rb index a8935ec542e..79920968603 100644 --- a/lib/uploaded_file.rb +++ b/lib/uploaded_file.rb @@ -78,15 +78,9 @@ class UploadedFile def sanitize_filename(name) name = name.tr("\\", "/") # work-around for IE name = ::File.basename(name) - - pre_sanitized_name = name - name = name.gsub(CarrierWave::SanitizedFile.sanitize_regexp, "_") name = "_#{name}" if name =~ /\A\.+\z/ name = "unnamed" if name.empty? - - @filename_sanitized = name != pre_sanitized_name - name.mb_chars.to_s end @@ -98,10 +92,6 @@ class UploadedFile @tempfile&.close end - def filename_sanitized? - @filename_sanitized - end - alias_method :local_path, :path def method_missing(method_name, *args, &block) #:nodoc: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ac28aee644a..a8116eaf14e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1027,9 +1027,6 @@ msgstr "" msgid "+ %{numberOfHiddenAssignees} more" msgstr "" -msgid "+ %{numberOfHiddenReviewers} more" -msgstr "" - msgid "+%d more" msgid_plural "+%d more" msgstr[0] "" @@ -1408,6 +1405,15 @@ msgstr "" msgid "APIFuzzing|Choose a profile" msgstr "" +msgid "APIFuzzing|Code snippet for the API Fuzzing configuration" +msgstr "" + +msgid "APIFuzzing|Copy code and open .gitlab-ci.yml file" +msgstr "" + +msgid "APIFuzzing|Copy code only" +msgstr "" + msgid "APIFuzzing|Customize common API fuzzing settings to suit your requirements. For details of more advanced configuration options, see the %{docsLinkStart}GitLab API Fuzzing documentation%{docsLinkEnd}." msgstr "" @@ -1456,6 +1462,9 @@ msgstr "" msgid "APIFuzzing|Target URL" msgstr "" +msgid "APIFuzzing|The configuration could not be saved, please try again later." +msgstr "" + msgid "APIFuzzing|There are two ways to perform scans." msgstr "" @@ -1914,6 +1923,9 @@ msgstr "" msgid "AddContextCommits|Add/remove" msgstr "" +msgid "AddMember|Invite limit of %{daily_invites} per day exceeded" +msgstr "" + msgid "AddMember|No users specified." msgstr "" @@ -7489,6 +7501,12 @@ msgstr "" msgid "Compliance Dashboard" msgstr "" +msgid "Compliance framework" +msgstr "" + +msgid "Compliance framework (optional)" +msgstr "" + msgid "Compliance framework (optional)" msgstr "" @@ -7829,15 +7847,9 @@ msgstr "" msgid "ContainerRegistry|Expiration policy will run in %{time}" msgstr "" -msgid "ContainerRegistry|Filter by name" -msgstr "" - msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password." msgstr "" -msgid "ContainerRegistry|Image Repositories" -msgstr "" - msgid "ContainerRegistry|Image repository deletion failed" msgstr "" @@ -8829,6 +8841,9 @@ msgstr "" msgid "Customizable by an administrator." msgstr "" +msgid "Customizable by owners." +msgstr "" + msgid "Customize colors" msgstr "" @@ -19948,6 +19963,9 @@ msgstr "" msgid "No commits present here" msgstr "" +msgid "No compliance frameworks are in use." +msgstr "" + msgid "No compliance frameworks are in use. Create one using the GraphQL API." msgstr "" @@ -20254,7 +20272,10 @@ msgstr "" msgid "Notes|Collapse replies" msgstr "" -msgid "Notes|Private comments are accessible by internal staff only" +msgid "Notes|Confidential comments are only visible to project members" +msgstr "" + +msgid "Notes|Make this comment confidential" msgstr "" msgid "Notes|Show all activity" @@ -20269,6 +20290,9 @@ msgstr "" msgid "Notes|This comment has changed since you started editing, please review the %{open_link}updated comment%{close_link} to ensure information is not lost" msgstr "" +msgid "Notes|This comment is confidential and only visible to project members" +msgstr "" + msgid "Notes|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options." msgstr "" @@ -25905,12 +25929,18 @@ msgstr "" msgid "SecurityConfiguration|Available for on-demand DAST" msgstr "" +msgid "SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}" +msgstr "" + msgid "SecurityConfiguration|By default, all analyzers are applied in order to cover all languages across your project, and only run if the language is detected in the Merge Request." msgstr "" msgid "SecurityConfiguration|Configure" msgstr "" +msgid "SecurityConfiguration|Configure via Merge Request" +msgstr "" + msgid "SecurityConfiguration|Could not retrieve configuration data. Please refresh the page, or try again later." msgstr "" @@ -25950,6 +25980,9 @@ msgstr "" msgid "SecurityConfiguration|SAST Configuration" msgstr "" +msgid "SecurityConfiguration|SAST merge request creation mutation failed" +msgstr "" + msgid "SecurityConfiguration|Security Control" msgstr "" @@ -26351,7 +26384,7 @@ msgstr "" msgid "Select projects" msgstr "" -msgid "Select required regulatory standard" +msgid "Select required regulatory standard." msgstr "" msgid "Select reviewer(s)" diff --git a/spec/controllers/concerns/redis_tracking_spec.rb b/spec/controllers/concerns/redis_tracking_spec.rb index ef59adf8c1d..53b49dd30a6 100644 --- a/spec/controllers/concerns/redis_tracking_spec.rb +++ b/spec/controllers/concerns/redis_tracking_spec.rb @@ -3,18 +3,13 @@ require "spec_helper" RSpec.describe RedisTracking do - let(:feature) { 'approval_rule' } let(:user) { create(:user) } - before do - skip_feature_flags_yaml_validation - end - controller(ApplicationController) do include RedisTracking skip_before_action :authenticate_user!, only: :show - track_redis_hll_event :index, :show, name: 'g_compliance_approval_rules', feature: :approval_rule, feature_default_enabled: true, + track_redis_hll_event :index, :show, name: 'g_compliance_approval_rules', if: [:custom_condition_one?, :custom_condition_two?] def index @@ -49,97 +44,75 @@ RSpec.describe RedisTracking do expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) end - context 'with feature disabled' do - it 'does not track the event' do - stub_feature_flags(feature => false) - - expect_no_tracking - - get :index - end - end - - context 'with feature enabled' do + context 'when user is logged in' do before do - stub_feature_flags(feature => true) + sign_in(user) end - context 'when user is logged in' do - before do - sign_in(user) - end - - it 'tracks the event' do - expect_tracking - - get :index - end - - it 'passes default_enabled flag' do - expect(controller).to receive(:metric_feature_enabled?).with(feature.to_sym, true) + it 'tracks the event' do + expect_tracking - get :index - end + get :index + end - it 'tracks the event if DNT is not enabled' do - request.headers['DNT'] = '0' + it 'tracks the event if DNT is not enabled' do + request.headers['DNT'] = '0' - expect_tracking + expect_tracking - get :index - end + get :index + end - it 'does not track the event if DNT is enabled' do - request.headers['DNT'] = '1' + it 'does not track the event if DNT is enabled' do + request.headers['DNT'] = '1' - expect_no_tracking + expect_no_tracking - get :index - end + get :index + end - it 'does not track the event if the format is not HTML' do - expect_no_tracking + it 'does not track the event if the format is not HTML' do + expect_no_tracking - get :index, format: :json - end + get :index, format: :json + end - it 'does not track the event if a custom condition returns false' do - expect(controller).to receive(:custom_condition_two?).and_return(false) + it 'does not track the event if a custom condition returns false' do + expect(controller).to receive(:custom_condition_two?).and_return(false) - expect_no_tracking + expect_no_tracking - get :index - end + get :index + end - it 'does not track the event for untracked actions' do - expect_no_tracking + it 'does not track the event for untracked actions' do + expect_no_tracking - get :new - end + get :new end + end - context 'when user is not logged in and there is a visitor_id' do - let(:visitor_id) { SecureRandom.uuid } + context 'when user is not logged in and there is a visitor_id' do + let(:visitor_id) { SecureRandom.uuid } - before do - routes.draw { get 'show' => 'anonymous#show' } - end + before do + routes.draw { get 'show' => 'anonymous#show' } + end - it 'tracks the event' do - cookies[:visitor_id] = { value: visitor_id, expires: 24.months } + it 'tracks the event' do + cookies[:visitor_id] = { value: visitor_id, expires: 24.months } - expect_tracking + expect_tracking - get :show - end + get :show end + end - context 'when user is not logged in and there is no visitor_id' do - it 'does not track the event' do - expect_no_tracking + context 'when user is not logged in and there is no visitor_id' do + it 'does not track the event' do + expect_no_tracking - get :index - end + get :index end end end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 16be7394174..68551ce4858 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -424,7 +424,7 @@ RSpec.describe Projects::BlobController do end end - it_behaves_like 'tracking unique hll events', :track_editor_edit_actions do + it_behaves_like 'tracking unique hll events' do subject(:request) { put :update, params: default_params } let(:target_id) { 'g_edit_by_sfe' } @@ -540,7 +540,7 @@ RSpec.describe Projects::BlobController do sign_in(user) end - it_behaves_like 'tracking unique hll events', :track_editor_edit_actions do + it_behaves_like 'tracking unique hll events' do subject(:request) { post :create, params: default_params } let(:target_id) { 'g_edit_by_sfe' } diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index bfa83f07503..edebaf294c4 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -315,7 +315,7 @@ RSpec.describe Projects::NotesController do let(:note_text) { 'some note' } let(:request_params) do { - note: { note: note_text, noteable_id: merge_request.id, noteable_type: 'MergeRequest' }, + note: { note: note_text, noteable_id: merge_request.id, noteable_type: 'MergeRequest' }.merge(extra_note_params), namespace_id: project.namespace, project_id: project, merge_request_diff_head_sha: 'sha', @@ -325,6 +325,7 @@ RSpec.describe Projects::NotesController do end let(:extra_request_params) { {} } + let(:extra_note_params) { {} } let(:project_visibility) { Gitlab::VisibilityLevel::PUBLIC } let(:merge_requests_access_level) { ProjectFeature::ENABLED } @@ -423,6 +424,41 @@ RSpec.describe Projects::NotesController do end end + context 'when creating a confidential note' do + let(:extra_request_params) { { format: :json } } + + context 'when `confidential` parameter is not provided' do + it 'sets `confidential` to `false` in JSON response' do + create! + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['confidential']).to be false + end + end + + context 'when `confidential` parameter is `false`' do + let(:extra_note_params) { { confidential: false } } + + it 'sets `confidential` to `false` in JSON response' do + create! + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['confidential']).to be false + end + end + + context 'when `confidential` parameter is `true`' do + let(:extra_note_params) { { confidential: true } } + + it 'sets `confidential` to `true` in JSON response' do + create! + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['confidential']).to be true + end + end + end + context 'when creating a note with quick actions' do context 'with commands that return changes' do let(:note_text) { "/award :thumbsup:\n/estimate 1d\n/spend 3h" } diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb index d10351feb9e..b625ce35d61 100644 --- a/spec/controllers/projects/refs_controller_spec.rb +++ b/spec/controllers/projects/refs_controller_spec.rb @@ -56,18 +56,6 @@ RSpec.describe Projects::RefsController do expect(response).to be_successful expect(json_response).to be_kind_of(Array) end - - it 'caches tree summary data', :use_clean_rails_memory_store_caching do - expect_next_instance_of(::Gitlab::TreeSummary) do |instance| - expect(instance).to receive_messages(summarize: ['logs'], next_offset: 50, more?: true) - end - - xhr_get(:json, offset: 25) - - cache_key = "projects/#{project.id}/logs/#{project.commit.id}/#{path}/25" - expect(Rails.cache.fetch(cache_key)).to eq(['logs', 50]) - expect(response.headers['More-Logs-Offset']).to eq("50") - end end end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index c531c699e98..95cea10f0d0 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -183,7 +183,7 @@ RSpec.describe SearchController do allow(Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event) end - it_behaves_like 'tracking unique hll events', :search_track_unique_users do + it_behaves_like 'tracking unique hll events' do subject(:request) { get :show, params: { scope: 'projects', search: 'term' } } let(:target_id) { 'i_search_total' } diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 51cecb348c8..50d6ac8f23d 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -173,7 +173,7 @@ RSpec.describe SnippetsController do expect(response).to have_gitlab_http_status(:ok) end - it_behaves_like 'tracking unique hll events', :usage_data_i_snippets_show do + it_behaves_like 'tracking unique hll events' do subject(:request) { get :show, params: { id: public_snippet.to_param } } let(:target_id) { 'i_snippets_show' } diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 83a866b0e62..e8e0362fc62 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -28,6 +28,7 @@ FactoryBot.define do forking_access_level { ProjectFeature::ENABLED } merge_requests_access_level { ProjectFeature::ENABLED } repository_access_level { ProjectFeature::ENABLED } + analytics_access_level { ProjectFeature::ENABLED } pages_access_level do visibility_level == Gitlab::VisibilityLevel::PUBLIC ? ProjectFeature::ENABLED : ProjectFeature::PRIVATE end @@ -63,7 +64,8 @@ FactoryBot.define do repository_access_level: evaluator.repository_access_level, pages_access_level: evaluator.pages_access_level, metrics_dashboard_access_level: evaluator.metrics_dashboard_access_level, - operations_access_level: evaluator.operations_access_level + operations_access_level: evaluator.operations_access_level, + analytics_access_level: evaluator.analytics_access_level } project.build_project_feature(hash) @@ -335,6 +337,9 @@ FactoryBot.define do trait(:operations_enabled) { operations_access_level { ProjectFeature::ENABLED } } trait(:operations_disabled) { operations_access_level { ProjectFeature::DISABLED } } trait(:operations_private) { operations_access_level { ProjectFeature::PRIVATE } } + trait(:analytics_enabled) { analytics_access_level { ProjectFeature::ENABLED } } + trait(:analytics_disabled) { analytics_access_level { ProjectFeature::DISABLED } } + trait(:analytics_private) { analytics_access_level { ProjectFeature::PRIVATE } } trait :auto_devops do association :auto_devops, factory: :project_auto_devops diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb index 1f8397e45f7..90647305281 100644 --- a/spec/features/help_pages_spec.rb +++ b/spec/features/help_pages_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'Help Pages' do it 'opens shortcuts help dialog' do find('.js-trigger-shortcut').click - expect(page).to have_selector('#modal-shortcuts') + expect(page).to have_selector('[data-testid="modal-shortcuts"]') end end end diff --git a/spec/features/projects/user_uses_shortcuts_spec.rb b/spec/features/projects/user_uses_shortcuts_spec.rb index 8fa5f741a95..13ae035e8ef 100644 --- a/spec/features/projects/user_uses_shortcuts_spec.rb +++ b/spec/features/projects/user_uses_shortcuts_spec.rb @@ -27,14 +27,13 @@ RSpec.describe 'User uses shortcuts', :js do open_modal_shortcut_keys - # modal-shortcuts still in the DOM, but hidden - expect(find('#modal-shortcuts', visible: false)).not_to be_visible + expect(page).not_to have_selector('[data-testid="modal-shortcuts"]') page.refresh open_modal_shortcut_keys # after reload, shortcuts modal doesn't exist at all until we add it - expect(page).not_to have_selector('#modal-shortcuts') + expect(page).not_to have_selector('[data-testid="modal-shortcuts"]') end it 're-enables shortcuts' do @@ -47,7 +46,7 @@ RSpec.describe 'User uses shortcuts', :js do close_modal open_modal_shortcut_keys - expect(find('#modal-shortcuts')).to be_visible + expect(find('[data-testid="modal-shortcuts"]')).to be_visible end def open_modal_shortcut_keys diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 8394d1d0ab2..2307510e119 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -1,7 +1,8 @@ import { nextTick } from 'vue'; import { mount, shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; import Autosize from 'autosize'; +import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; @@ -21,11 +22,25 @@ describe('issue_comment_form component', () => { let wrapper; let axiosMock; - const findCloseReopenButton = () => wrapper.find('[data-testid="close-reopen-button"]'); + const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button'); + const findCommentButton = () => wrapper.findByTestId('comment-button'); + const findTextArea = () => wrapper.findByTestId('comment-field'); + const findConfidentialNoteCheckbox = () => wrapper.findByTestId('confidential-note-checkbox'); + + const createNotableDataMock = (data = {}) => { + return { + ...noteableDataMock, + ...data, + }; + }; - const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); + const notableDataMockCanUpdateIssuable = createNotableDataMock({ + current_user: { can_update: true, can_create_note: true }, + }); - const findTextArea = () => wrapper.find('[data-testid="comment-field"]'); + const notableDataMockCannotUpdateIssuable = createNotableDataMock({ + current_user: { can_update: false, can_create_note: true }, + }); const mountComponent = ({ initialData = {}, @@ -33,23 +48,29 @@ describe('issue_comment_form component', () => { noteableData = noteableDataMock, notesData = notesDataMock, userData = userDataMock, + features = {}, mountFunction = shallowMount, } = {}) => { store.dispatch('setNoteableData', noteableData); store.dispatch('setNotesData', notesData); store.dispatch('setUserData', userData); - wrapper = mountFunction(CommentForm, { - propsData: { - noteableType, - }, - data() { - return { - ...initialData, - }; - }, - store, - }); + wrapper = extendedWrapper( + mountFunction(CommentForm, { + propsData: { + noteableType, + }, + data() { + return { + ...initialData, + }; + }, + store, + provide: { + glFeatures: features, + }, + }), + ); }; beforeEach(() => { @@ -359,6 +380,83 @@ describe('issue_comment_form component', () => { }); }); }); + + describe('confidential notes checkbox', () => { + describe('when confidentialNotes feature flag is `false`', () => { + const features = { confidentialNotes: false }; + + it('should not render checkbox', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: 'confidential note' }, + noteableData: { ...notableDataMockCanUpdateIssuable }, + features, + }); + + const checkbox = findConfidentialNoteCheckbox(); + expect(checkbox.exists()).toBe(false); + }); + }); + + describe('when confidentialNotes feature flag is `true`', () => { + const features = { confidentialNotes: true }; + + it('should render checkbox as unchecked by default', () => { + mountComponent({ + mountFunction: mount, + initialData: { note: 'confidential note' }, + noteableData: { ...notableDataMockCanUpdateIssuable }, + features, + }); + + const checkbox = findConfidentialNoteCheckbox(); + expect(checkbox.exists()).toBe(true); + expect(checkbox.element.checked).toBe(false); + }); + + describe.each` + shouldCheckboxBeChecked + ${true} + ${false} + `('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => { + it(`sets \`confidential\` to \`${shouldCheckboxBeChecked}\``, async () => { + mountComponent({ + mountFunction: mount, + initialData: { note: 'confidential note' }, + noteableData: { ...notableDataMockCanUpdateIssuable }, + features, + }); + + jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({}); + + const checkbox = findConfidentialNoteCheckbox(); + + // check checkbox + checkbox.element.checked = shouldCheckboxBeChecked; + checkbox.trigger('change'); + await wrapper.vm.$nextTick(); + + // submit comment + wrapper.findByTestId('comment-button').trigger('click'); + + const [providedData] = wrapper.vm.saveNote.mock.calls[0]; + expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked); + }); + }); + + describe('when user cannot update issuable', () => { + it('should not render checkbox', () => { + mountComponent({ + mountFunction: mount, + noteableData: { ...notableDataMockCannotUpdateIssuable }, + features, + }); + + expect(findConfidentialNoteCheckbox().exists()).toBe(false); + }); + }); + }); + }); }); describe('user is not logged in', () => { diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index 9c49ff0c9e5..5fbe58e35e2 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -1,6 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui'; +import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; @@ -13,12 +13,12 @@ import RegistryHeader from '~/registry/explorer/components/list_page/registry_he import ImageList from '~/registry/explorer/components/list_page/image_list.vue'; import DeleteImage from '~/registry/explorer/components/delete_image.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, - IMAGE_REPOSITORY_LIST_LABEL, - SEARCH_PLACEHOLDER_TEXT, + SORT_FIELDS, } from '~/registry/explorer/constants'; import getContainerRepositoriesDetails from '~/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; @@ -55,8 +55,7 @@ describe('List Page', () => { const findDeleteAlert = () => wrapper.find(GlAlert); const findImageList = () => wrapper.find(ImageList); - const findListHeader = () => wrapper.find('[data-testid="listHeader"]'); - const findSearchBox = () => wrapper.find(GlSearchBoxByClick); + const findRegistrySearch = () => wrapper.find(RegistrySearch); const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); const findDeleteImage = () => wrapper.find(DeleteImage); @@ -229,14 +228,6 @@ describe('List Page', () => { expect(findCliCommands().exists()).toBe(false); }); - - it('list header is not visible', async () => { - mountComponent({ resolver, config }); - - await waitForApolloRequestRender(); - - expect(findListHeader().exists()).toBe(false); - }); }); }); @@ -258,16 +249,6 @@ describe('List Page', () => { expect(findImageList().exists()).toBe(true); }); - it('list header is visible', async () => { - mountComponent(); - - await waitForApolloRequestRender(); - - const header = findListHeader(); - expect(header.exists()).toBe(true); - expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL); - }); - describe('additional metadata', () => { it('is called on component load', async () => { const detailsResolver = jest @@ -360,10 +341,15 @@ describe('List Page', () => { }); }); - describe('search', () => { + describe('search and sorting', () => { const doSearch = async () => { await waitForApolloRequestRender(); - findSearchBox().vm.$emit('submit', 'centos6'); + findRegistrySearch().vm.$emit('filter:changed', [ + { type: 'filtered-search-term', value: { data: 'centos6' } }, + ]); + + findRegistrySearch().vm.$emit('filter:submit'); + await wrapper.vm.$nextTick(); }; @@ -372,9 +358,26 @@ describe('List Page', () => { await waitForApolloRequestRender(); - const searchBox = findSearchBox(); - expect(searchBox.exists()).toBe(true); - expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT); + const registrySearch = findRegistrySearch(); + expect(registrySearch.exists()).toBe(true); + expect(registrySearch.props()).toMatchObject({ + filter: [], + sorting: { orderBy: 'UPDATED', sort: 'desc' }, + sortableFields: SORT_FIELDS, + tokens: [], + }); + }); + + it('performs sorting', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); + await wrapper.vm.$nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); }); it('performs a search', async () => { diff --git a/spec/frontend/security_configuration/app_spec.js b/spec/frontend/security_configuration/app_spec.js new file mode 100644 index 00000000000..11d481fb210 --- /dev/null +++ b/spec/frontend/security_configuration/app_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import App from '~/security_configuration/components/app.vue'; +import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; + +describe('App Component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(App, {}); + }; + const findConfigurationTable = () => wrapper.findComponent(ConfigurationTable); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correct primary & Secondary Heading', () => { + createComponent(); + expect(wrapper.text()).toContain('Security Configuration'); + expect(wrapper.text()).toContain('Testing & Compliance'); + }); + + it('renders ConfigurationTable Component', () => { + createComponent(); + expect(findConfigurationTable().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js new file mode 100644 index 00000000000..beeca1b7169 --- /dev/null +++ b/spec/frontend/security_configuration/configuration_table_spec.js @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils'; +import ConfigurationTable from '~/security_configuration/components/configuration_table.vue'; +import { features, UPGRADE_CTA } from '~/security_configuration/components/features_constants'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +import { + REPORT_TYPE_SAST, + REPORT_TYPE_DAST, + REPORT_TYPE_DEPENDENCY_SCANNING, + REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_LICENSE_COMPLIANCE, +} from '~/vue_shared/security_reports/constants'; + +describe('Configuration Table Component', () => { + let wrapper; + + const createComponent = () => { + wrapper = extendedWrapper(mount(ConfigurationTable, {})); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + createComponent(); + }); + + it.each(features)('should match strings', (feature) => { + expect(wrapper.text()).toContain(feature.name); + expect(wrapper.text()).toContain(feature.description); + + if (feature.type === REPORT_TYPE_SAST) { + expect(wrapper.findByTestId(feature.type).text()).toBe('Configure via Merge Request'); + } else if ( + [ + REPORT_TYPE_DAST, + REPORT_TYPE_DEPENDENCY_SCANNING, + REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_LICENSE_COMPLIANCE, + ].includes(feature.type) + ) { + expect(wrapper.findByTestId(feature.type).text()).toMatchInterpolatedText(UPGRADE_CTA); + } + }); +}); diff --git a/spec/frontend/security_configuration/manage_sast_spec.js b/spec/frontend/security_configuration/manage_sast_spec.js new file mode 100644 index 00000000000..6d3cfeb9ed1 --- /dev/null +++ b/spec/frontend/security_configuration/manage_sast_spec.js @@ -0,0 +1,136 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql'; +import ManageSast from '~/security_configuration/components/manage_sast.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), +})); + +Vue.use(VueApollo); + +describe('Manage Sast Component', () => { + let wrapper; + + const findButton = () => wrapper.findComponent(GlButton); + const successHandler = async () => { + return { + data: { + configureSast: { + successPath: 'testSuccessPath', + errors: [], + __typename: 'ConfigureSastPayload', + }, + }, + }; + }; + + const noSuccessPathHandler = async () => { + return { + data: { + configureSast: { + successPath: '', + errors: [], + __typename: 'ConfigureSastPayload', + }, + }, + }; + }; + + const errorHandler = async () => { + return { + data: { + configureSast: { + successPath: 'testSuccessPath', + errors: ['foo'], + __typename: 'ConfigureSastPayload', + }, + }, + }; + }; + + const pendingHandler = () => new Promise(() => {}); + + function createMockApolloProvider(handler) { + const requestHandlers = [[configureSastMutation, handler]]; + + return createMockApollo(requestHandlers); + } + + function createComponent(options = {}) { + const { mockApollo } = options; + wrapper = extendedWrapper( + mount(ManageSast, { + apolloProvider: mockApollo, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render Button with correct text', () => { + createComponent(); + expect(findButton().text()).toContain('Configure via Merge Request'); + }); + + describe('given a successful response', () => { + beforeEach(() => { + const mockApollo = createMockApolloProvider(successHandler); + createComponent({ mockApollo }); + }); + + it('should call redirect helper with correct value', async () => { + await wrapper.trigger('click'); + await waitForPromises(); + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith('testSuccessPath'); + // This is done for UX reasons. If the loading prop is set to false + // on success, then there's a period where the button is clickable + // again. Instead, we want the button to display a loading indicator + // for the remainder of the lifetime of the page (i.e., until the + // browser can start painting the new page it's been redirected to). + expect(findButton().props().loading).toBe(true); + }); + }); + + describe('given a pending response', () => { + beforeEach(() => { + const mockApollo = createMockApolloProvider(pendingHandler); + createComponent({ mockApollo }); + }); + + it('renders spinner correctly', async () => { + expect(findButton().props('loading')).toBe(false); + await wrapper.trigger('click'); + await waitForPromises(); + expect(findButton().props('loading')).toBe(true); + }); + }); + + describe.each` + handler | message + ${noSuccessPathHandler} | ${'SAST merge request creation mutation failed'} + ${errorHandler} | ${'foo'} + `('given an error response', ({ handler, message }) => { + beforeEach(() => { + const mockApollo = createMockApolloProvider(handler); + createComponent({ mockApollo }); + }); + + it('should catch and emit error', async () => { + await wrapper.trigger('click'); + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[message]]); + expect(findButton().props('loading')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js new file mode 100644 index 00000000000..53434d32f07 --- /dev/null +++ b/spec/frontend/security_configuration/upgrade_spec.js @@ -0,0 +1,29 @@ +import { mount } from '@vue/test-utils'; +import Upgrade from '~/security_configuration/components/upgrade.vue'; +import { UPGRADE_CTA } from '~/security_configuration/components/features_constants'; + +let wrapper; +const createComponent = () => { + wrapper = mount(Upgrade, {}); +}; + +beforeEach(() => { + createComponent(); +}); + +afterEach(() => { + wrapper.destroy(); +}); + +describe('Upgrade component', () => { + it('renders correct text in link', () => { + expect(wrapper.text()).toMatchInterpolatedText(UPGRADE_CTA); + }); + + it('renders link with correct attributes', () => { + expect(wrapper.find('a').attributes()).toMatchObject({ + href: 'https://about.gitlab.com/pricing/', + target: '_blank', + }); + }); +}); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js new file mode 100644 index 00000000000..41180fcc4c2 --- /dev/null +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -0,0 +1,91 @@ +import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import UncollapsedReviewerList from '~/sidebar/components/reviewers/uncollapsed_reviewer_list.vue'; +import ReviewerAvatarLink from '~/sidebar/components/reviewers/reviewer_avatar_link.vue'; +import userDataMock from '../../user_data_mock'; + +describe('UncollapsedReviewerList component', () => { + let wrapper; + + function createComponent(props = {}) { + const propsData = { + users: [], + rootPath: TEST_HOST, + ...props, + }; + + wrapper = shallowMount(UncollapsedReviewerList, { + propsData, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('single reviewer', () => { + beforeEach(() => { + const user = userDataMock(); + + createComponent({ + users: [user], + }); + }); + + it('only has one user', () => { + expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(1); + }); + + it('shows one user with avatar, username and author name', () => { + expect(wrapper.text()).toContain(`@root`); + }); + + it('renders re-request loading icon', async () => { + await wrapper.setData({ loadingStates: { 1: 'loading' } }); + + expect(wrapper.find('[data-testid="re-request-button"]').props('loading')).toBe(true); + }); + + it('renders re-request success icon', async () => { + await wrapper.setData({ loadingStates: { 1: 'success' } }); + + expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); + }); + }); + + describe('multiple reviewers', () => { + beforeEach(() => { + const user = userDataMock(); + + createComponent({ + users: [user, { ...user, id: 2, username: 'hello-world' }], + }); + }); + + it('only has one user', () => { + expect(wrapper.findAll(ReviewerAvatarLink).length).toBe(2); + }); + + it('shows one user with avatar, username and author name', () => { + expect(wrapper.text()).toContain(`@root`); + expect(wrapper.text()).toContain(`@hello-world`); + }); + + it('renders re-request loading icon', async () => { + await wrapper.setData({ loadingStates: { 2: 'loading' } }); + + expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(2); + expect(wrapper.findAll('[data-testid="re-request-button"]').at(1).props('loading')).toBe( + true, + ); + }); + + it('renders re-request success icon', async () => { + await wrapper.setData({ loadingStates: { 2: 'success' } }); + + expect(wrapper.findAll('[data-testid="re-request-button"]').length).toBe(1); + expect(wrapper.findAll('[data-testid="re-request-success"]').length).toBe(1); + expect(wrapper.find('[data-testid="re-request-success"]').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/user_data_mock.js b/spec/frontend/sidebar/user_data_mock.js index df90a65f6f9..41d0331f34a 100644 --- a/spec/frontend/sidebar/user_data_mock.js +++ b/spec/frontend/sidebar/user_data_mock.js @@ -8,4 +8,6 @@ export default () => ({ username: 'root', web_url: `${TEST_HOST}/root`, can_merge: true, + can_update_merge_request: true, + reviewed: true, }); diff --git a/spec/graphql/mutations/design_management/upload_spec.rb b/spec/graphql/mutations/design_management/upload_spec.rb index 2fdf62c35a2..326d88cea80 100644 --- a/spec/graphql/mutations/design_management/upload_spec.rb +++ b/spec/graphql/mutations/design_management/upload_spec.rb @@ -4,7 +4,6 @@ require 'spec_helper' RSpec.describe Mutations::DesignManagement::Upload do include DesignManagementTestHelpers include ConcurrentHelpers - using FixtureFileRefinements let(:issue) { create(:issue) } let(:user) { issue.author } @@ -19,12 +18,8 @@ RSpec.describe Mutations::DesignManagement::Upload do mutation.resolve(project_path: project_path, iid: iid, files: files_to_upload) end - def uploaded_file(filename) - fixture_file_upload(expand_fixture_path(filename)) - end - describe "#resolve" do - let(:files) { [uploaded_file('dk.png').to_gitlab_uploaded_file] } + let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] } subject(:resolve) do mutation.resolve(project_path: project.full_path, iid: issue.iid, files: files) @@ -54,7 +49,7 @@ RSpec.describe Mutations::DesignManagement::Upload do ['dk.png', 'rails_sample.jpg', 'banana_sample.gif'] .cycle .take(Concurrent.processor_count * 2) - .map { |f| uploaded_file(f).uniquely_named.to_gitlab_uploaded_file } + .map { |f| RenameableUpload.unique_file(f) } end def creates_designs diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb index 0eff33bb25b..5b3662383d8 100644 --- a/spec/graphql/types/user_type_spec.rb +++ b/spec/graphql/types/user_type_spec.rb @@ -10,6 +10,7 @@ RSpec.describe GitlabSchema.types['User'] do it 'has the expected fields' do expected_fields = %w[ id + bot user_permissions snippets name diff --git a/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb b/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb new file mode 100644 index 00000000000..4bbd60d4970 --- /dev/null +++ b/spec/lib/bulk_imports/groups/graphql/get_members_query_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Graphql::GetMembersQuery do + it 'has a valid query' do + entity = create(:bulk_import_entity) + + query = GraphQL::Query.new( + GitlabSchema, + described_class.to_s, + variables: described_class.variables(entity) + ) + result = GitlabSchema.static_validator.validate(query) + + expect(result[:errors]).to be_empty + end + + describe '#data_path' do + it 'returns data path' do + expected = %w[data group group_members nodes] + + expect(described_class.data_path).to eq(expected) + end + end + + describe '#page_info_path' do + it 'returns pagination information path' do + expected = %w[data group group_members page_info] + + expect(described_class.page_info_path).to eq(expected) + end + end +end diff --git a/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb new file mode 100644 index 00000000000..d552578e7be --- /dev/null +++ b/spec/lib/bulk_imports/groups/loaders/members_loader_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Loaders::MembersLoader do + describe '#load' do + let_it_be(:user_importer) { create(:user) } + let_it_be(:user_member) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:bulk_import) { create(:bulk_import, user: user_importer) } + let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) } + + let_it_be(:data) do + { + 'user_id' => user_member.id, + 'created_by_id' => user_importer.id, + 'access_level' => 30, + 'created_at' => '2020-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'expires_at' => nil + } + end + + it 'does nothing when there is no data' do + expect { subject.load(context, nil) }.not_to change(GroupMember, :count) + end + + it 'creates the member' do + expect { subject.load(context, data) }.to change(GroupMember, :count).by(1) + + member = group.members.last + + expect(member.user).to eq(user_member) + expect(member.created_by).to eq(user_importer) + expect(member.access_level).to eq(30) + expect(member.created_at).to eq('2020-01-01T00:00:00Z') + expect(member.updated_at).to eq('2020-01-01T00:00:00Z') + expect(member.expires_at).to eq(nil) + end + end +end diff --git a/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb new file mode 100644 index 00000000000..52208e2b852 --- /dev/null +++ b/spec/lib/bulk_imports/groups/pipelines/members_pipeline_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Pipelines::MembersPipeline do + let_it_be(:member_user1) { create(:user, email: 'email1@email.com') } + let_it_be(:member_user2) { create(:user, email: 'email2@email.com') } + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:cursor) { 'cursor' } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) } + + describe '#run' do + it 'maps existing users to the imported group' do + first_page = member_data(email: member_user1.email, has_next_page: true, cursor: cursor) + last_page = member_data(email: member_user2.email, has_next_page: false) + + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor) + .to receive(:extract) + .and_return(first_page, last_page) + end + + expect { subject.run(context) }.to change(GroupMember, :count).by(2) + + members = group.members.map { |m| m.slice(:user_id, :access_level) } + + expect(members).to contain_exactly( + { user_id: member_user1.id, access_level: 30 }, + { user_id: member_user2.id, access_level: 30 } + ) + end + end + + describe 'pipeline parts' do + it { expect(described_class).to include_module(BulkImports::Pipeline) } + it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } + + it 'has extractors' do + expect(described_class.get_extractor) + .to eq( + klass: BulkImports::Common::Extractors::GraphqlExtractor, + options: { + query: BulkImports::Groups::Graphql::GetMembersQuery + } + ) + end + + it 'has transformers' do + expect(described_class.transformers) + .to contain_exactly( + { klass: BulkImports::Common::Transformers::ProhibitedAttributesTransformer, options: nil }, + { klass: BulkImports::Groups::Transformers::MemberAttributesTransformer, options: nil } + ) + end + + it 'has loaders' do + expect(described_class.get_loader).to eq(klass: BulkImports::Groups::Loaders::MembersLoader, options: nil) + end + end + + def member_data(email:, has_next_page:, cursor: nil) + data = { + 'created_at' => '2020-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'expires_at' => nil, + 'access_level' => { + 'integer_value' => 30 + }, + 'user' => { + 'public_email' => email + } + } + + page_info = { + 'end_cursor' => cursor, + 'has_next_page' => has_next_page + } + + BulkImports::Pipeline::ExtractedData.new(data: data, page_info: page_info) + end +end diff --git a/spec/lib/bulk_imports/groups/transformers/member_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/member_attributes_transformer_spec.rb new file mode 100644 index 00000000000..f66c67fc6a2 --- /dev/null +++ b/spec/lib/bulk_imports/groups/transformers/member_attributes_transformer_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Transformers::MemberAttributesTransformer do + let_it_be(:user) { create(:user) } + let_it_be(:secondary_email) { 'secondary@email.com' } + let_it_be(:group) { create(:group) } + let_it_be(:bulk_import) { create(:bulk_import, user: user) } + let_it_be(:entity) { create(:bulk_import_entity, bulk_import: bulk_import, group: group) } + let_it_be(:context) { BulkImports::Pipeline::Context.new(entity) } + + it 'returns nil when receives no data' do + expect(subject.transform(context, nil)).to eq(nil) + end + + it 'returns nil when no user is found' do + expect(subject.transform(context, member_data)).to eq(nil) + expect(subject.transform(context, member_data(email: 'inexistent@email.com'))).to eq(nil) + end + + context 'when the user is not confirmed' do + before do + user.update!(confirmed_at: nil) + end + + it 'returns nil even when the primary email match' do + data = member_data(email: user.email) + + expect(subject.transform(context, data)).to eq(nil) + end + + it 'returns nil even when a secondary email match' do + user.emails << Email.new(email: secondary_email) + data = member_data(email: secondary_email) + + expect(subject.transform(context, data)).to eq(nil) + end + end + + context 'when the user is confirmed' do + before do + user.update!(confirmed_at: Time.now.utc) + end + + it 'finds the user by the primary email' do + data = member_data(email: user.email) + + expect(subject.transform(context, data)).to eq( + 'access_level' => 30, + 'user_id' => user.id, + 'created_by_id' => user.id, + 'created_at' => '2020-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'expires_at' => nil + ) + end + + it 'finds the user by the secondary email' do + user.emails << Email.new(email: secondary_email, confirmed_at: Time.now.utc) + data = member_data(email: secondary_email) + + expect(subject.transform(context, data)).to eq( + 'access_level' => 30, + 'user_id' => user.id, + 'created_by_id' => user.id, + 'created_at' => '2020-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'expires_at' => nil + ) + end + + context 'format access level' do + it 'ignores record if no access level is given' do + data = member_data(email: user.email, access_level: nil) + + expect(subject.transform(context, data)).to be_nil + end + + it 'ignores record if is not a valid access level' do + data = member_data(email: user.email, access_level: 999) + + expect(subject.transform(context, data)).to be_nil + end + end + end + + def member_data(email: '', access_level: 30) + { + 'created_at' => '2020-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'expires_at' => nil, + 'access_level' => { + 'integer_value' => access_level + }, + 'user' => { + 'public_email' => email + } + } + end +end diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb index 0884a51ce7d..43e12e6e3d7 100644 --- a/spec/lib/bulk_imports/importers/group_importer_spec.rb +++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb @@ -18,9 +18,10 @@ RSpec.describe BulkImports::Importers::GroupImporter do describe '#execute' do it 'starts the entity and run its pipelines' do expect_to_run_pipeline BulkImports::Groups::Pipelines::GroupPipeline, context: context + expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context + expect_to_run_pipeline BulkImports::Groups::Pipelines::MembersPipeline, context: context expect_to_run_pipeline BulkImports::Groups::Pipelines::LabelsPipeline, context: context expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context) if Gitlab.ee? - expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context subject.execute diff --git a/spec/lib/gitlab/auth/otp/strategies/forti_token_cloud_spec.rb b/spec/lib/gitlab/auth/otp/strategies/forti_token_cloud_spec.rb index 1580fc82279..368cf98dfec 100644 --- a/spec/lib/gitlab/auth/otp/strategies/forti_token_cloud_spec.rb +++ b/spec/lib/gitlab/auth/otp/strategies/forti_token_cloud_spec.rb @@ -13,6 +13,8 @@ RSpec.describe Gitlab::Auth::Otp::Strategies::FortiTokenCloud do let(:otp_verification_url) { url + '/auth' } let(:access_token) { 'an_access_token' } let(:access_token_create_response_body) { '' } + let(:access_token_request_body) { { client_id: client_id, client_secret: client_secret } } + let(:headers) { { 'Content-Type': 'application/json' } } subject(:validate) { described_class.new(user).validate(otp_code) } @@ -27,11 +29,8 @@ RSpec.describe Gitlab::Auth::Otp::Strategies::FortiTokenCloud do client_secret: client_secret ) - access_token_request_body = { client_id: client_id, - client_secret: client_secret } - stub_request(:post, access_token_create_url) - .with(body: JSON(access_token_request_body), headers: { 'Content-Type' => 'application/json' }) + .with(body: JSON(access_token_request_body), headers: headers) .to_return( status: access_token_create_response_status, body: Gitlab::Json.generate(access_token_create_response_body), @@ -81,6 +80,20 @@ RSpec.describe Gitlab::Auth::Otp::Strategies::FortiTokenCloud do end end + context 'SSL Verification' do + let(:access_token_create_response_status) { 400 } + + context 'with `Gitlab::HTTP`' do + it 'does not use a `verify` argument,'\ + 'thereby always performing SSL verification while making API calls' do + expect(Gitlab::HTTP).to receive(:post) + .with(access_token_create_url, body: JSON(access_token_request_body), headers: headers).and_call_original + + validate + end + end + end + def stub_forti_token_cloud_config(forti_token_cloud_settings) allow(::Gitlab.config.forti_token_cloud).to(receive_messages(forti_token_cloud_settings)) end diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb index ae3270cb9b2..7aaeee32f49 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb @@ -74,6 +74,14 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Validate::Abilities do it 'does not break the chain' do expect(step.break?).to eq false end + + context 'when project is deleted' do + before do + project.update!(pending_delete: true) + end + + specify { expect(step.perform!).to contain_exactly('Project is deleted!') } + end end describe '#allowed_to_write_ref?' do diff --git a/spec/lib/gitlab/tree_summary_spec.rb b/spec/lib/gitlab/tree_summary_spec.rb index 303a4a80581..d2c5844b0fa 100644 --- a/spec/lib/gitlab/tree_summary_spec.rb +++ b/spec/lib/gitlab/tree_summary_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::TreeSummary do + include RepoHelpers using RSpec::Parameterized::TableSyntax let(:project) { create(:project, :empty_repo) } @@ -44,6 +45,40 @@ RSpec.describe Gitlab::TreeSummary do expect(commits).to match_array(entries.map { |entry| entry[:commit] }) end end + + context 'when offset is over the limit' do + let(:offset) { 100 } + + it 'returns an empty array' do + expect(summarized).to eq([[], []]) + end + end + + context 'with caching', :use_clean_rails_memory_store_caching do + subject { Rails.cache.fetch(key) } + + before do + summarized + end + + context 'Repository tree cache' do + let(:key) { ['projects', project.id, 'content', commit.id, path] } + + it 'creates a cache for repository content' do + is_expected.to eq([{ file_name: 'a.txt', type: :blob }]) + end + end + + context 'Commits list cache' do + let(:offset) { 0 } + let(:limit) { 25 } + let(:key) { ['projects', project.id, 'last_commits_list', commit.id, path, offset, limit] } + + it 'creates a cache for commits list' do + is_expected.to eq('a.txt' => commit.to_hash) + end + end + end end describe '#summarize (entries)' do @@ -167,6 +202,46 @@ RSpec.describe Gitlab::TreeSummary do end end + describe 'References in commit messages' do + let_it_be(:project) { create(:project, :empty_repo) } + let_it_be(:issue) { create(:issue, project: project) } + let(:entries) { summary.summarize.first } + let(:entry) { entries.find { |entry| entry[:file_name] == 'issue.txt' } } + + before_all do + create_file_in_repo(project, 'master', 'master', 'issue.txt', '', commit_message: "Issue ##{issue.iid}") + end + + where(:project_visibility, :user_role, :issue_confidential, :expected_result) do + 'private' | :guest | false | true + 'private' | :guest | true | false + 'private' | :reporter | false | true + 'private' | :reporter | true | true + + 'internal' | :guest | false | true + 'internal' | :guest | true | false + 'internal' | :reporter | false | true + 'internal' | :reporter | true | true + + 'public' | :guest | false | true + 'public' | :guest | true | false + 'public' | :reporter | false | true + 'public' | :reporter | true | true + end + + with_them do + subject { entry[:commit_title_html].include?("title=\"#{issue.title}\"") } + + before do + project.add_role(user, user_role) + project.update!(visibility_level: Gitlab::VisibilityLevel.level_value(project_visibility)) + issue.update!(confidential: issue_confidential) + end + + it { is_expected.to eq(expected_result) } + end + end + describe '#more?' do let(:path) { 'tmp/more' } diff --git a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb index 08b103a1179..5469ded18f9 100644 --- a/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb +++ b/spec/lib/gitlab/usage/metrics/aggregates/aggregate_spec.rb @@ -222,6 +222,12 @@ RSpec.describe Gitlab::Usage::Metrics::Aggregates::Aggregate, :clean_gitlab_redi end end + it 'allows for YAML aliases in aggregated metrics configs' do + expect(YAML).to receive(:safe_load).with(kind_of(String), aliases: true) + + described_class.new(recorded_at) + end + describe '.aggregated_metrics_weekly_data' do subject(:aggregated_metrics_data) { described_class.new(recorded_at).weekly_data } diff --git a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb index ba7bfe47bc9..4b07f9143b5 100644 --- a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do describe '.track_unique_project_event' do described_class::TEMPLATE_TO_EVENT.keys.each do |template| context "when given template #{template}" do - it_behaves_like 'tracking unique hll events', :usage_data_track_ci_templates_unique_projects do + it_behaves_like 'tracking unique hll events' do subject(:request) { described_class.track_unique_project_event(project_id: project_id, template: template) } let(:target_id) { "p_ci_templates_#{described_class::TEMPLATE_TO_EVENT[template]}" } diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb index 579cc1e372a..b4894ec049f 100644 --- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb @@ -48,6 +48,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s end describe 'known_events' do + let(:feature) { 'test_hll_redis_counter_ff_check' } + let(:weekly_event) { 'g_analytics_contribution' } let(:daily_event) { 'g_analytics_search' } let(:analytics_slot_event) { 'g_analytics_contribution' } @@ -67,7 +69,7 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s let(:known_events) do [ - { name: weekly_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "weekly" }, + { name: weekly_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "weekly", feature_flag: feature }, { name: daily_event, redis_slot: "analytics", category: analytics_category, expiry: 84, aggregation: "daily" }, { name: category_productivity_event, redis_slot: "analytics", category: productivity_category, aggregation: "weekly" }, { name: compliance_slot_event, redis_slot: "compliance", category: compliance_category, aggregation: "weekly" }, @@ -78,6 +80,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s end before do + skip_feature_flags_yaml_validation + skip_default_enabled_yaml_check allow(described_class).to receive(:known_events).and_return(known_events) end @@ -88,6 +92,32 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s end describe '.track_event' do + context 'with feature flag set' do + it 'tracks the event when feature enabled' do + stub_feature_flags(feature => true) + + expect(Gitlab::Redis::HLL).to receive(:add) + + described_class.track_event(weekly_event, values: 1) + end + + it 'does not track the event with feature flag disabled' do + stub_feature_flags(feature => false) + + expect(Gitlab::Redis::HLL).not_to receive(:add) + + described_class.track_event(weekly_event, values: 1) + end + end + + context 'with no feature flag set' do + it 'tracks the event' do + expect(Gitlab::Redis::HLL).to receive(:add) + + described_class.track_event(daily_event, values: 1) + end + end + context 'when usage_ping is disabled' do it 'does not track the event' do stub_application_setting(usage_ping_enabled: false) diff --git a/spec/lib/uploaded_file_spec.rb b/spec/lib/uploaded_file_spec.rb index 04eef823852..ececc84bc93 100644 --- a/spec/lib/uploaded_file_spec.rb +++ b/spec/lib/uploaded_file_spec.rb @@ -218,20 +218,6 @@ RSpec.describe UploadedFile do end end - describe '#filename_sanitized?' do - it 'is true when filename has been sanitized' do - file = described_class.new(temp_file.path, filename: 'foo①.png') - - expect(file).to be_filename_sanitized - end - - it 'is false when filename has not been sanitized' do - file = described_class.new(temp_file.path, filename: 'foo.png') - - expect(file).not_to be_filename_sanitized - end - end - describe '#sanitize_filename' do it { expect(described_class.new(temp_file.path).sanitize_filename('spaced name')).to eq('spaced_name') } it { expect(described_class.new(temp_file.path).sanitize_filename('#$%^&')).to eq('_____') } diff --git a/spec/migrations/insert_daily_invites_plan_limits_spec.rb b/spec/migrations/insert_daily_invites_plan_limits_spec.rb new file mode 100644 index 00000000000..3265efcb0ce --- /dev/null +++ b/spec/migrations/insert_daily_invites_plan_limits_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20201007033723_insert_daily_invites_plan_limits.rb') + +RSpec.describe InsertDailyInvitesPlanLimits do + let(:plans) { table(:plans) } + let(:plan_limits) { table(:plan_limits) } + let!(:free_plan) { plans.create!(name: 'free') } + let!(:bronze_plan) { plans.create!(name: 'bronze') } + let!(:silver_plan) { plans.create!(name: 'silver') } + let!(:gold_plan) { plans.create!(name: 'gold') } + + context 'when on Gitlab.com' do + before do + expect(Gitlab).to receive(:com?).at_most(:twice).and_return(true) + end + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(plan_limits.where.not(daily_invites: 0)).to be_empty + } + + # Expectations will run after the up migration. + migration.after -> { + expect(plan_limits.pluck(:plan_id, :daily_invites)).to contain_exactly( + [free_plan.id, 20], + [bronze_plan.id, 0], + [silver_plan.id, 0], + [gold_plan.id, 0] + ) + } + end + end + end + + context 'when on self hosted' do + before do + expect(Gitlab).to receive(:com?).at_most(:twice).and_return(false) + end + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(plan_limits.pluck(:daily_invites)).to eq [] + } + + migration.after -> { + expect(plan_limits.pluck(:daily_invites)).to eq [] + } + end + end + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 6f5f7d2e2fb..94943fb3644 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -34,6 +34,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it { is_expected.to have_many(:auto_canceled_jobs) } it { is_expected.to have_many(:sourced_pipelines) } it { is_expected.to have_many(:triggered_pipelines) } + it { is_expected.to have_many(:pipeline_artifacts) } it { is_expected.to have_one(:chat_data) } it { is_expected.to have_one(:source_pipeline) } @@ -41,14 +42,15 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do it { is_expected.to have_one(:source_job) } it { is_expected.to have_one(:pipeline_config) } - it { is_expected.to validate_presence_of(:sha) } - it { is_expected.to validate_presence_of(:status) } - it { is_expected.to respond_to :git_author_name } it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } it { is_expected.to delegate_method(:full_path).to(:project).with_prefix } - it { is_expected.to have_many(:pipeline_artifacts) } + + describe 'validations' do + it { is_expected.to validate_presence_of(:sha) } + it { is_expected.to validate_presence_of(:status) } + end describe 'associations' do it 'has a bidirectional relationship with projects' do diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 1a791820f1b..b60af7abade 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -171,6 +171,43 @@ RSpec.describe Member do end end + describe '.in_hierarchy' do + let(:root_ancestor) { create(:group) } + let(:project) { create(:project, group: root_ancestor) } + let(:subgroup) { create(:group, parent: root_ancestor) } + let(:subgroup_project) { create(:project, group: subgroup) } + + let!(:root_ancestor_member) { create(:group_member, group: root_ancestor) } + let!(:project_member) { create(:project_member, project: project) } + let!(:subgroup_member) { create(:group_member, group: subgroup) } + let!(:subgroup_project_member) { create(:project_member, project: subgroup_project) } + + let(:hierarchy_members) do + [ + root_ancestor_member, + project_member, + subgroup_member, + subgroup_project_member + ] + end + + subject { Member.in_hierarchy(project) } + + it { is_expected.to contain_exactly(*hierarchy_members) } + + context 'with scope prefix' do + subject { Member.where.not(source: project).in_hierarchy(subgroup) } + + it { is_expected.to contain_exactly(root_ancestor_member, subgroup_member, subgroup_project_member) } + end + + context 'with scope suffix' do + subject { Member.in_hierarchy(project).where.not(source: project) } + + it { is_expected.to contain_exactly(root_ancestor_member, subgroup_member, subgroup_project_member) } + end + end + describe '.invite' do it { expect(described_class.invite).not_to include @maintainer } it { expect(described_class.invite).to include @invited_member } @@ -251,6 +288,21 @@ RSpec.describe Member do it { is_expected.to include(expiring_tomorrow, not_expiring) } end + describe '.created_today' do + let_it_be(:now) { Time.current } + let_it_be(:created_today) { create(:group_member, created_at: now.beginning_of_day) } + let_it_be(:created_yesterday) { create(:group_member, created_at: now - 1.day) } + + before do + travel_to now + end + + subject { described_class.created_today } + + it { is_expected.not_to include(created_yesterday) } + it { is_expected.to include(created_today) } + end + describe '.last_ten_days_excluding_today' do let_it_be(:now) { Time.current } let_it_be(:created_today) { create(:group_member, created_at: now.beginning_of_day) } diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb index 67fb11f34e0..4259c8b708b 100644 --- a/spec/models/plan_limits_spec.rb +++ b/spec/models/plan_limits_spec.rb @@ -209,6 +209,7 @@ RSpec.describe PlanLimits do ci_pipeline_size ci_active_jobs storage_size_limit + daily_invites ] + disabled_max_artifact_size_columns end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb index 8215fb5c336..ea63406e615 100644 --- a/spec/models/project_services/prometheus_service_spec.rb +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -2,11 +2,13 @@ require 'spec_helper' +require 'googleauth' + RSpec.describe PrometheusService, :use_clean_rails_memory_store_caching, :snowplow do include PrometheusHelpers include ReactiveCachingHelpers - let(:project) { create(:prometheus_project) } + let_it_be_with_reload(:project) { create(:prometheus_project) } let(:service) { project.prometheus_service } describe "Associations" do @@ -256,19 +258,66 @@ RSpec.describe PrometheusService, :use_clean_rails_memory_store_caching, :snowpl context 'behind IAP' do let(:manual_configuration) { true } - before do - # dummy private key generated only for this test to pass openssl validation - service.google_iap_service_account_json = '{"type":"service_account","private_key":"-----BEGIN RSA PRIVATE KEY-----\nMIIBOAIBAAJAU85LgUY5o6j6j/07GMLCNUcWJOBA1buZnNgKELayA6mSsHrIv31J\nY8kS+9WzGPQninea7DcM4hHA7smMgQD1BwIDAQABAkAqKxMy6PL3tn7dFL43p0ex\nJyOtSmlVIiAZG1t1LXhE/uoLpYi5DnbYqGgu0oih+7nzLY/dXpNpXUmiRMOUEKmB\nAiEAoTi2rBXbrLSi2C+H7M/nTOjMQQDuZ8Wr4uWpKcjYJTMCIQCFEskL565oFl/7\nRRQVH+cARrAsAAoJSbrOBAvYZ0PI3QIgIEFwis10vgEF86rOzxppdIG/G+JL0IdD\n9IluZuXAGPECIGUo7qSaLr75o2VEEgwtAFH5aptIPFjrL5LFCKwtdB4RAiAYZgFV\nHCMmaooAw/eELuMoMWNYmujZ7VaAnOewGDW0uw==\n-----END RSA PRIVATE KEY-----\n"}' - service.google_iap_audience_client_id = "IAP_CLIENT_ID.apps.googleusercontent.com" + let(:google_iap_service_account) do + { + type: "service_account", + # dummy private key generated only for this test to pass openssl validation + private_key: <<~KEY + -----BEGIN RSA PRIVATE KEY----- + MIIBOAIBAAJAU85LgUY5o6j6j/07GMLCNUcWJOBA1buZnNgKELayA6mSsHrIv31J + Y8kS+9WzGPQninea7DcM4hHA7smMgQD1BwIDAQABAkAqKxMy6PL3tn7dFL43p0ex + JyOtSmlVIiAZG1t1LXhE/uoLpYi5DnbYqGgu0oih+7nzLY/dXpNpXUmiRMOUEKmB + AiEAoTi2rBXbrLSi2C+H7M/nTOjMQQDuZ8Wr4uWpKcjYJTMCIQCFEskL565oFl/7 + RRQVH+cARrAsAAoJSbrOBAvYZ0PI3QIgIEFwis10vgEF86rOzxppdIG/G+JL0IdD + 9IluZuXAGPECIGUo7qSaLr75o2VEEgwtAFH5aptIPFjrL5LFCKwtdB4RAiAYZgFV + HCMmaooAw/eELuMoMWNYmujZ7VaAnOewGDW0uw== + -----END RSA PRIVATE KEY----- + KEY + } + end + + def stub_iap_request + service.google_iap_service_account_json = Gitlab::Json.generate(google_iap_service_account) + service.google_iap_audience_client_id = 'IAP_CLIENT_ID.apps.googleusercontent.com' - stub_request(:post, "https://oauth2.googleapis.com/token").to_return(status: 200, body: '{"id_token": "FOO"}', headers: { 'Content-Type': 'application/json; charset=UTF-8' }) + stub_request(:post, 'https://oauth2.googleapis.com/token') + .to_return( + status: 200, + body: '{"id_token": "FOO"}', + headers: { 'Content-Type': 'application/json; charset=UTF-8' } + ) end it 'includes the authorization header' do + stub_iap_request + expect(service.prometheus_client).not_to be_nil expect(service.prometheus_client.send(:options)).to have_key(:headers) expect(service.prometheus_client.send(:options)[:headers]).to eq(authorization: "Bearer FOO") end + + context 'when passed with token_credential_uri', issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/284819' do + let(:malicious_host) { 'http://example.com' } + + where(:param_name) do + [ + :token_credential_uri, + :tokencredentialuri, + :Token_credential_uri, + :tokenCredentialUri + ] + end + + with_them do + it 'does not make any unexpected HTTP requests' do + google_iap_service_account[param_name] = malicious_host + stub_iap_request + stub_request(:any, malicious_host).to_raise('Making additional HTTP requests is forbidden!') + + expect(service.prometheus_client).not_to be_nil + end + end + end end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index efa1afb758f..6ba3ab6aace 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -1057,6 +1057,78 @@ RSpec.describe ProjectPolicy do it { is_expected.to be_allowed(:read_analytics) } end + context 'with various analytics features' do + let_it_be(:project_with_analytics_disabled) { create(:project, :analytics_disabled) } + let_it_be(:project_with_analytics_private) { create(:project, :analytics_private) } + let_it_be(:project_with_analytics_enabled) { create(:project, :analytics_enabled) } + + before do + project_with_analytics_disabled.add_developer(developer) + project_with_analytics_private.add_developer(developer) + project_with_analytics_enabled.add_developer(developer) + end + + context 'when analytics is enabled for the project' do + let(:project) { project_with_analytics_disabled } + + context 'for guest user' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:read_cycle_analytics) } + it { is_expected.to be_disallowed(:read_insights) } + it { is_expected.to be_disallowed(:read_repository_graphs) } + end + + context 'for developer' do + let(:current_user) { developer } + + it { is_expected.to be_disallowed(:read_cycle_analytics) } + it { is_expected.to be_disallowed(:read_insights) } + it { is_expected.to be_disallowed(:read_repository_graphs) } + end + end + + context 'when analytics is private for the project' do + let(:project) { project_with_analytics_private } + + context 'for guest user' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:read_cycle_analytics) } + it { is_expected.to be_disallowed(:read_insights) } + it { is_expected.to be_disallowed(:read_repository_graphs) } + end + + context 'for developer' do + let(:current_user) { developer } + + it { is_expected.to be_allowed(:read_cycle_analytics) } + it { is_expected.to be_allowed(:read_insights) } + it { is_expected.to be_allowed(:read_repository_graphs) } + end + end + + context 'when analytics is enabled for the project' do + let(:project) { project_with_analytics_private } + + context 'for guest user' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:read_cycle_analytics) } + it { is_expected.to be_disallowed(:read_insights) } + it { is_expected.to be_disallowed(:read_repository_graphs) } + end + + context 'for developer' do + let(:current_user) { developer } + + it { is_expected.to be_allowed(:read_cycle_analytics) } + it { is_expected.to be_allowed(:read_insights) } + it { is_expected.to be_allowed(:read_repository_graphs) } + end + end + end + context 'project member' do let(:project) { private_project } diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb index 2653653c896..2316e702c3e 100644 --- a/spec/requests/api/lint_spec.rb +++ b/spec/requests/api/lint_spec.rb @@ -4,91 +4,136 @@ require 'spec_helper' RSpec.describe API::Lint do describe 'POST /ci/lint' do - context 'with valid .gitlab-ci.yaml content' do - let(:yaml_content) do - File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) - end + context 'when signup settings are disabled' do + Gitlab::CurrentSettings.signup_enabled = false - it 'passes validation without warnings or errors' do - post api('/ci/lint'), params: { content: yaml_content } + context 'when unauthenticated' do + it 'returns authentication error' do + post api('/ci/lint'), params: { content: 'content' } - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_an Hash - expect(json_response['status']).to eq('valid') - expect(json_response['warnings']).to eq([]) - expect(json_response['errors']).to eq([]) + expect(response).to have_gitlab_http_status(:unauthorized) + end end - it 'outputs expanded yaml content' do - post api('/ci/lint'), params: { content: yaml_content, include_merged_yaml: true } + context 'when authenticated' do + it 'returns unauthorized error' do + post api('/ci/lint'), params: { content: 'content' } - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to have_key('merged_yaml') + expect(response).to have_gitlab_http_status(:unauthorized) + end end end - context 'with valid .gitlab-ci.yaml with warnings' do - let(:yaml_content) { { job: { script: 'ls', rules: [{ when: 'always' }] } }.to_yaml } + context 'when signup settings are enabled' do + Gitlab::CurrentSettings.signup_enabled = true - it 'passes validation but returns warnings' do - post api('/ci/lint'), params: { content: yaml_content } + context 'when unauthenticated' do + it 'returns authentication error' do + post api('/ci/lint'), params: { content: 'content' } - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['status']).to eq('valid') - expect(json_response['warnings']).not_to be_empty - expect(json_response['status']).to eq('valid') - expect(json_response['errors']).to eq([]) + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when authenticated' do + let_it_be(:api_user) { create(:user) } + it 'returns authentication success' do + post api('/ci/lint', api_user), params: { content: 'content' } + + expect(response).to have_gitlab_http_status(:ok) + end end end - context 'with an invalid .gitlab_ci.yml' do - context 'with invalid syntax' do - let(:yaml_content) { 'invalid content' } + context 'when authenticated' do + let_it_be(:api_user) { create(:user) } - it 'responds with errors about invalid syntax' do - post api('/ci/lint'), params: { content: yaml_content } + context 'with valid .gitlab-ci.yaml content' do + let(:yaml_content) do + File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) + end + + it 'passes validation without warnings or errors' do + post api('/ci/lint', api_user), params: { content: yaml_content } expect(response).to have_gitlab_http_status(:ok) - expect(json_response['status']).to eq('invalid') + expect(json_response).to be_an Hash + expect(json_response['status']).to eq('valid') expect(json_response['warnings']).to eq([]) - expect(json_response['errors']).to eq(['Invalid configuration format']) + expect(json_response['errors']).to eq([]) end it 'outputs expanded yaml content' do - post api('/ci/lint'), params: { content: yaml_content, include_merged_yaml: true } + post api('/ci/lint', api_user), params: { content: yaml_content, include_merged_yaml: true } expect(response).to have_gitlab_http_status(:ok) expect(json_response).to have_key('merged_yaml') end end - context 'with invalid configuration' do - let(:yaml_content) { '{ image: "ruby:2.7", services: ["postgres"], invalid }' } + context 'with valid .gitlab-ci.yaml with warnings' do + let(:yaml_content) { { job: { script: 'ls', rules: [{ when: 'always' }] } }.to_yaml } - it 'responds with errors about invalid configuration' do - post api('/ci/lint'), params: { content: yaml_content } + it 'passes validation but returns warnings' do + post api('/ci/lint', api_user), params: { content: yaml_content } expect(response).to have_gitlab_http_status(:ok) - expect(json_response['status']).to eq('invalid') - expect(json_response['warnings']).to eq([]) - expect(json_response['errors']).to eq(['jobs invalid config should implement a script: or a trigger: keyword', 'jobs config should contain at least one visible job']) + expect(json_response['status']).to eq('valid') + expect(json_response['warnings']).not_to be_empty + expect(json_response['status']).to eq('valid') + expect(json_response['errors']).to eq([]) end + end - it 'outputs expanded yaml content' do - post api('/ci/lint'), params: { content: yaml_content, include_merged_yaml: true } + context 'with an invalid .gitlab_ci.yml' do + context 'with invalid syntax' do + let(:yaml_content) { 'invalid content' } - expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to have_key('merged_yaml') + it 'responds with errors about invalid syntax' do + post api('/ci/lint', api_user), params: { content: yaml_content } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['status']).to eq('invalid') + expect(json_response['warnings']).to eq([]) + expect(json_response['errors']).to eq(['Invalid configuration format']) + end + + it 'outputs expanded yaml content' do + post api('/ci/lint', api_user), params: { content: yaml_content, include_merged_yaml: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key('merged_yaml') + end + end + + context 'with invalid configuration' do + let(:yaml_content) { '{ image: "ruby:2.7", services: ["postgres"] }' } + + it 'responds with errors about invalid configuration' do + post api('/ci/lint', api_user), params: { content: yaml_content } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['status']).to eq('invalid') + expect(json_response['warnings']).to eq([]) + expect(json_response['errors']).to eq(['jobs config should contain at least one visible job']) + end + + it 'outputs expanded yaml content' do + post api('/ci/lint', api_user), params: { content: yaml_content, include_merged_yaml: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to have_key('merged_yaml') + end end end - end - context 'without the content parameter' do - it 'responds with validation error about missing content' do - post api('/ci/lint') + context 'without the content parameter' do + it 'responds with validation error about missing content' do + post api('/ci/lint', api_user) - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eq('content is missing') + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('content is missing') + end end end end @@ -364,6 +409,18 @@ RSpec.describe API::Lint do expect(response).to have_gitlab_http_status(:not_found) end + + context 'when project is public' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + + it 'returns authentication error' do + ci_lint + + expect(response).to have_gitlab_http_status(:forbidden) + end + end end context 'when authenticated as non-member' do @@ -387,13 +444,10 @@ RSpec.describe API::Lint do context 'when running as dry run' do let(:dry_run) { true } - it 'returns pipeline creation error' do + it 'returns authentication error' do ci_lint - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['merged_yaml']).to eq(nil) - expect(json_response['valid']).to eq(false) - expect(json_response['errors']).to eq(['Insufficient permissions to create a new pipeline']) + expect(response).to have_gitlab_http_status(:forbidden) end end @@ -410,7 +464,11 @@ RSpec.describe API::Lint do ) end - it_behaves_like 'valid project config' + it 'returns authentication error' do + ci_lint + + expect(response).to have_gitlab_http_status(:forbidden) + end end end end diff --git a/spec/requests/api/merge_request_approvals_spec.rb b/spec/requests/api/merge_request_approvals_spec.rb index fad5c3fb60e..b18f3017e03 100644 --- a/spec/requests/api/merge_request_approvals_spec.rb +++ b/spec/requests/api/merge_request_approvals_spec.rb @@ -21,6 +21,12 @@ RSpec.describe API::MergeRequestApprovals do expect(response).to have_gitlab_http_status(:ok) end + + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/approvals" } + end + end end describe 'POST :id/merge_requests/:merge_request_iid/approve' do diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb index 2e6cbe7bee7..971fb5e991c 100644 --- a/spec/requests/api/merge_request_diffs_spec.rb +++ b/spec/requests/api/merge_request_diffs_spec.rb @@ -35,6 +35,12 @@ RSpec.describe API::MergeRequestDiffs, 'MergeRequestDiffs' do get api("/projects/#{project.id}/merge_requests/0/versions", user) expect(response).to have_gitlab_http_status(:not_found) end + + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions" } + end + end end describe 'GET /projects/:id/merge_requests/:merge_request_iid/versions/:version_id' do @@ -63,5 +69,11 @@ RSpec.describe API::MergeRequestDiffs, 'MergeRequestDiffs' do get api("/projects/#{project.id}/merge_requests/#{non_existing_record_iid}/versions/#{merge_request_diff.id}", user) expect(response).to have_gitlab_http_status(:not_found) end + + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions/#{merge_request_diff.id}" } + end + end end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index b459d3cd8d3..ad8e21bf4c1 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -1226,6 +1226,12 @@ RSpec.describe API::MergeRequests do end end + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}" } + end + end + context 'merge_request_metrics' do let(:pipeline) { create(:ci_empty_pipeline) } @@ -1411,6 +1417,12 @@ RSpec.describe API::MergeRequests do it_behaves_like 'issuable participants endpoint' do let(:entity) { create(:merge_request, :simple, milestone: milestone1, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) } end + + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/participants" } + end + end end describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do @@ -1436,6 +1448,12 @@ RSpec.describe API::MergeRequests do expect(response).to have_gitlab_http_status(:not_found) end + + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/commits" } + end + end end describe 'GET /projects/:id/merge_requests/:merge_request_iid/:context_commits' do @@ -1511,6 +1529,12 @@ RSpec.describe API::MergeRequests do expect(response).to have_gitlab_http_status(:not_found) end + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes" } + end + end + it_behaves_like 'find an existing merge request' it_behaves_like 'accesses diffs via raw_diffs' @@ -1600,6 +1624,12 @@ RSpec.describe API::MergeRequests do expect(response).to have_gitlab_http_status(:forbidden) end end + + context 'when merge request author has only guest access' do + it_behaves_like 'rejects user from accessing merge request info' do + let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/pipelines" } + end + end end describe 'POST /projects/:id/merge_requests/:merge_request_iid/pipelines' do diff --git a/spec/requests/api/npm_instance_packages_spec.rb b/spec/requests/api/npm_instance_packages_spec.rb index 8299717b5c7..70c76067a6e 100644 --- a/spec/requests/api/npm_instance_packages_spec.rb +++ b/spec/requests/api/npm_instance_packages_spec.rb @@ -6,7 +6,7 @@ RSpec.describe API::NpmInstancePackages do include_context 'npm api setup' describe 'GET /api/v4/packages/npm/*package_name' do - it_behaves_like 'handling get metadata requests' do + it_behaves_like 'handling get metadata requests', scope: :instance do let(:url) { api("/packages/npm/#{package_name}") } end end diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb index 1421f20ac28..7ea238c0607 100644 --- a/spec/requests/api/npm_project_packages_spec.rb +++ b/spec/requests/api/npm_project_packages_spec.rb @@ -6,25 +6,25 @@ RSpec.describe API::NpmProjectPackages do include_context 'npm api setup' describe 'GET /api/v4/projects/:id/packages/npm/*package_name' do - it_behaves_like 'handling get metadata requests' do + it_behaves_like 'handling get metadata requests', scope: :project do let(:url) { api("/projects/#{project.id}/packages/npm/#{package_name}") } end end describe 'GET /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags' do - it_behaves_like 'handling get dist tags requests' do + it_behaves_like 'handling get dist tags requests', scope: :project do let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags") } end end describe 'PUT /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag' do - it_behaves_like 'handling create dist tag requests' do + it_behaves_like 'handling create dist tag requests', scope: :project do let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") } end end describe 'DELETE /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag' do - it_behaves_like 'handling delete dist tag requests' do + it_behaves_like 'handling delete dist tag requests', scope: :project do let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") } end end @@ -32,10 +32,14 @@ RSpec.describe API::NpmProjectPackages do describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do let_it_be(:package_file) { package.package_files.first } - let(:params) { {} } - let(:url) { api("/projects/#{project.id}/packages/npm/#{package_file.package.name}/-/#{package_file.file_name}") } + let(:headers) { {} } + let(:url) { api("/projects/#{project.id}/packages/npm/#{package.name}/-/#{package_file.file_name}") } - subject { get(url, params: params) } + subject { get(url, headers: headers) } + + before do + project.add_developer(user) + end shared_examples 'a package file that requires auth' do it 'denies download with no token' do @@ -45,7 +49,7 @@ RSpec.describe API::NpmProjectPackages do end context 'with access token' do - let(:params) { { access_token: token.token } } + let(:headers) { build_token_auth_header(token.token) } it 'returns the file' do subject @@ -56,7 +60,7 @@ RSpec.describe API::NpmProjectPackages do end context 'with job token' do - let(:params) { { job_token: job.token } } + let(:headers) { build_token_auth_header(job.token) } it 'returns the file' do subject @@ -86,7 +90,7 @@ RSpec.describe API::NpmProjectPackages do it_behaves_like 'a package file that requires auth' context 'with guest' do - let(:params) { { access_token: token.token } } + let(:headers) { build_token_auth_header(token.token) } it 'denies download when not enough permissions' do project.add_guest(user) @@ -108,7 +112,11 @@ RSpec.describe API::NpmProjectPackages do end describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do - RSpec.shared_examples 'handling invalid record with 400 error' do + before do + project.add_developer(user) + end + + shared_examples 'handling invalid record with 400 error' do it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do expect { upload_package_with_token(package_name, params) } .not_to change { project.packages.count } @@ -261,7 +269,9 @@ RSpec.describe API::NpmProjectPackages do end def upload_package(package_name, params = {}) - put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params + token = params.delete(:access_token) || params.delete(:job_token) + headers = build_token_auth_header(token) + put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params, headers: headers end def upload_package_with_token(package_name, params = {}) diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml new file mode 100644 index 00000000000..181fcafd577 --- /dev/null +++ b/spec/requests/api/project_attributes.yml @@ -0,0 +1,149 @@ +--- +itself: # project + unexposed_attributes: + - bfg_object_map + - delete_error + - detected_repository_languages + - disable_overriding_approvers_per_merge_request + - external_authorization_classification_label + - external_webhook_token + - has_external_issue_tracker + - has_external_wiki + - import_source + - import_type + - import_url + - issues_template + - jobs_cache_index + - last_repository_check_at + - last_repository_check_failed + - last_repository_updated_at + - marked_for_deletion_at + - marked_for_deletion_by_user_id + - max_artifacts_size + - max_pages_size + - merge_requests_author_approval + - merge_requests_disable_committers_approval + - merge_requests_rebase_enabled + - merge_requests_template + - mirror_last_successful_update_at + - mirror_last_update_at + - mirror_overwrites_diverged_branches + - mirror_trigger_builds + - mirror_user_id + - only_mirror_protected_branches + - pages_https_only + - pending_delete + - pool_repository_id + - pull_mirror_available_overridden + - pull_mirror_branch_prefix + - remote_mirror_available_overridden + - repository_read_only + - repository_size_limit + - require_password_to_approve + - reset_approvals_on_push + - runners_token_encrypted + - storage_version + - updated_at + remapped_attributes: + avatar: avatar_url + build_allow_git_fetch: build_git_strategy + merge_requests_ff_only_enabled: merge_method + namespace_id: namespace + public_builds: public_jobs + visibility_level: visibility + computed_attributes: + - _links + - can_create_merge_request_in + - compliance_frameworks + - container_expiration_policy + - default_branch + - empty_repo + - forks_count + - http_url_to_repo + - name_with_namespace + - open_issues_count + - owner + - path_with_namespace + - permissions + - readme_url + - shared_with_groups + - ssh_url_to_repo + - web_url + +build_auto_devops: # auto_devops + unexposed_attributes: + - id + - project_id + - created_at + - updated_at + remapped_attributes: + enabled: auto_devops_enabled + deploy_strategy: auto_devops_deploy_strategy + +ci_cd_settings: + unexposed_attributes: + - id + - project_id + - group_runners_enabled + - keep_latest_artifact + - merge_pipelines_enabled + - merge_trains_enabled + - auto_rollback_enabled + remapped_attributes: + default_git_depth: ci_default_git_depth + forward_deployment_enabled: ci_forward_deployment_enabled + +build_import_state: # import_state + unexposed_attributes: + - id + - project_id + - retry_count + - last_update_started_at + - last_update_scheduled_at + - next_execution_timestamp + - jid + - last_update_at + - last_successful_update_at + - correlation_id_value + remapped_attributes: + status: import_status + last_error: import_error + +project_feature: + unexposed_attributes: + - id + - created_at + - metrics_dashboard_access_level + - project_id + - requirements_access_level + - security_and_compliance_access_level + - updated_at + computed_attributes: + - issues_enabled + - jobs_enabled + - merge_requests_enabled + - requirements_enabled + - security_and_compliance_enabled + - snippets_enabled + - wiki_enabled + +project_setting: + unexposed_attributes: + - allow_editing_commit_messages + - created_at + - has_confluence + - has_vulnerabilities + - prevent_merge_without_jira_issue + - project_id + - push_rule_id + - show_default_award_emojis + - squash_option + - updated_at + +build_service_desk_setting: # service_desk_setting + unexposed_attributes: + - project_id + - issue_template_key + - outgoing_name + remapped_attributes: + project_key: service_desk_address diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 6d8cdde2c4f..ad36777184a 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -886,6 +886,7 @@ RSpec.describe API::Projects do merge_method: 'ff' }).tap do |attrs| attrs[:operations_access_level] = 'disabled' + attrs[:analytics_access_level] = 'disabled' end post api('/projects', user), params: project @@ -1539,6 +1540,35 @@ RSpec.describe API::Projects do end context 'when authenticated as an admin' do + let(:project_attributes_file) { 'spec/requests/api/project_attributes.yml' } + let(:project_attributes) { YAML.load_file(project_attributes_file) } + + let(:expected_keys) do + keys = project_attributes.map do |relation, relation_config| + begin + actual_keys = project.send(relation).attributes.keys + rescue NoMethodError + actual_keys = ["#{relation} is nil"] + end + unexposed_attributes = relation_config['unexposed_attributes'] || [] + remapped_attributes = relation_config['remapped_attributes'] || {} + computed_attributes = relation_config['computed_attributes'] || [] + actual_keys - unexposed_attributes - remapped_attributes.keys + remapped_attributes.values + computed_attributes + end.flatten + + unless Gitlab.ee? + keys -= %w[ + approvals_before_merge + compliance_frameworks + mirror + requirements_enabled + security_and_compliance_enabled + ] + end + + keys + end + it 'returns a project by id' do project project_member @@ -1587,6 +1617,27 @@ RSpec.describe API::Projects do expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved) expect(json_response['operations_access_level']).to be_present end + + it 'exposes all necessary attributes' do + create(:project_group_link, project: project) + + get api("/projects/#{project.id}", admin) + + diff = Set.new(json_response.keys) ^ Set.new(expected_keys) + + expect(diff).to be_empty, failure_message(diff) + end + + def failure_message(diff) + <<~MSG + It looks like project's set of exposed attributes is different from the expected set. + + The following attributes are missing or newly added: + #{diff.to_a.to_sentence} + + Please update #{project_attributes_file} file" + MSG + end end context 'when authenticated as a regular user' do diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb index bfdb5458fd1..2cb3c8e9ab5 100644 --- a/spec/requests/api/terraform/state_spec.rb +++ b/spec/requests/api/terraform/state_spec.rb @@ -39,7 +39,7 @@ RSpec.describe API::Terraform::State do context 'with maintainer permissions' do let(:current_user) { maintainer } - it_behaves_like 'tracking unique hll events', :usage_data_p_terraform_state_api_unique_users do + it_behaves_like 'tracking unique hll events' do let(:target_id) { 'p_terraform_state_api_unique_users' } let(:expected_type) { instance_of(Integer) } end diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index eaffa49fc9d..00de1ef5964 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -331,6 +331,14 @@ RSpec.describe API::Todos do expect(response).to have_gitlab_http_status(:not_found) end end + + it 'returns an error if the issuable author does not have access' do + project_1.add_guest(issuable.author) + + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", issuable.author) + + expect(response).to have_gitlab_http_status(:not_found) + end end describe 'POST :id/issuable_type/:issueable_id/todo' do diff --git a/spec/services/ci/abort_project_pipelines_service_spec.rb b/spec/services/ci/abort_project_pipelines_service_spec.rb new file mode 100644 index 00000000000..9af909ac2ab --- /dev/null +++ b/spec/services/ci/abort_project_pipelines_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::AbortProjectPipelinesService do + let_it_be(:project) { create(:project) } + let_it_be(:pipeline) { create(:ci_pipeline, :running, project: project) } + let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline) } + + describe '#execute' do + it 'cancels all running pipelines and related jobs' do + result = described_class.new.execute(project) + + expect(result).to be_success + expect(pipeline.reload).to be_canceled + expect(build.reload).to be_canceled + end + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new { described_class.new.execute(project) }.count + + pipelines = create_list(:ci_pipeline, 5, :running, project: project) + create_list(:ci_build, 5, :running, pipeline: pipelines.first) + + expect { described_class.new.execute(project) }.not_to exceed_query_limit(control_count) + end + end + + context 'when feature disabled' do + before do + stub_feature_flags(abort_deleted_project_pipelines: false) + end + + it 'does not abort the pipeline' do + result = described_class.new.execute(project) + + expect(result).to be(nil) + expect(pipeline.reload).to be_running + expect(build.reload).to be_running + end + end +end diff --git a/spec/services/design_management/save_designs_service_spec.rb b/spec/services/design_management/save_designs_service_spec.rb index 3e1641e2db2..f36e68c8dbd 100644 --- a/spec/services/design_management/save_designs_service_spec.rb +++ b/spec/services/design_management/save_designs_service_spec.rb @@ -4,7 +4,6 @@ require 'spec_helper' RSpec.describe DesignManagement::SaveDesignsService do include DesignManagementTestHelpers include ConcurrentHelpers - using FixtureFileRefinements let_it_be_with_reload(:issue) { create(:issue) } let_it_be(:developer) { create(:user, developer_projects: [issue.project]) } @@ -13,11 +12,11 @@ RSpec.describe DesignManagement::SaveDesignsService do let(:files) { [rails_sample] } let(:design_repository) { ::Gitlab::GlRepository::DESIGN.repository_resolver.call(project) } let(:rails_sample_name) { 'rails_sample.jpg' } - let(:rails_sample) { uploaded_file(rails_sample_name).to_gitlab_uploaded_file } - let(:dk_png) { uploaded_file('dk.png').to_gitlab_uploaded_file } + let(:rails_sample) { sample_image(rails_sample_name) } + let(:dk_png) { sample_image('dk.png') } - def uploaded_file(filename) - fixture_file_upload(expand_fixture_path(filename)) + def sample_image(filename) + fixture_file_upload("spec/fixtures/#{filename}") end def commit_count @@ -123,8 +122,7 @@ RSpec.describe DesignManagement::SaveDesignsService do parellism = 4 blocks = Array.new(parellism).map do - unique_file = uploaded_file('dk.png').uniquely_named.to_gitlab_uploaded_file - unique_files = [unique_file] + unique_files = [RenameableUpload.unique_file('rails_sample.jpg')] -> { run_service(unique_files) } end @@ -308,14 +306,6 @@ RSpec.describe DesignManagement::SaveDesignsService do expect(response[:message]).to match('Duplicate filenames are not allowed!') end end - - context 'when uploading files with special characters in filenames' do - let(:files) { [uploaded_file('dk.png').renamed_as('special_char①.png').to_gitlab_uploaded_file] } - - it 'returns the correct error' do - expect(response[:message]).to match('Filenames contained invalid characters and could not be saved') - end - end end context 'when the user is not allowed to upload designs' do diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index f0f09218b06..75d1c98923a 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -69,6 +69,12 @@ RSpec.describe Projects::DestroyService, :aggregate_failures do destroy_project(project, user, {}) end + it 'performs cancel for project ci pipelines' do + expect(::Ci::AbortProjectPipelinesService).to receive_message_chain(:new, :execute).with(project) + + destroy_project(project, user, {}) + end + context 'when project has remote mirrors' do let!(:project) do create(:project, :repository, namespace: user.namespace).tap do |project| diff --git a/spec/support/refinements/fixture_file_refinements.rb b/spec/support/refinements/fixture_file_refinements.rb deleted file mode 100644 index fd5fcf73200..00000000000 --- a/spec/support/refinements/fixture_file_refinements.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module FixtureFileRefinements - refine Rack::Test::UploadedFile do - # Recast this instance of `Rack::Test::UploadedFile` to an `::UploadedFile`. - def to_gitlab_uploaded_file - ::UploadedFile.new(path, filename: original_filename, content_type: content_type || 'application/octet-stream').tap do |file| - # `UploadedFile#tempfile` is read-only, so replace this with the writeable fixture file - file.instance_variable_set(:@tempfile, self) - end - end - - # Renames `original_filename` to something guaranteed to be unique. - def uniquely_named - name = File.basename(FactoryBot.generate(:filename), '.*') - extension = File.extname(original_filename) - unique_filename = name + extension - - renamed_as(unique_filename) - end - - def renamed_as(new_filename) - tap { @original_filename = new_filename } - end - end -end diff --git a/spec/support/renameable_upload.rb b/spec/support/renameable_upload.rb new file mode 100644 index 00000000000..f7f00181605 --- /dev/null +++ b/spec/support/renameable_upload.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class RenameableUpload < SimpleDelegator + attr_accessor :original_filename + + # Get a fixture file with a new unique name, and the same extension + def self.unique_file(name) + upload = new(fixture_file_upload("spec/fixtures/#{name}")) + ext = File.extname(name) + new_name = File.basename(FactoryBot.generate(:filename), '.*') + upload.original_filename = new_name + ext + + upload + end +end diff --git a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb index 7c23ec33cf8..60a29d78084 100644 --- a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb +++ b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb @@ -4,10 +4,10 @@ RSpec.shared_context 'npm api setup' do include PackagesManagerApiSpecHelpers include HttpBasicAuthHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user, reload: true) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:project, reload: true) { create(:project, :public, namespace: group) } - let_it_be(:package, reload: true) { create(:npm_package, project: project) } + let_it_be(:package, reload: true) { create(:npm_package, project: project, name: "@#{group.path}/scoped_package") } let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) } let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) } @@ -15,8 +15,15 @@ RSpec.shared_context 'npm api setup' do let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } let(:package_name) { package.name } +end - before do - project.add_developer(user) +RSpec.shared_context 'set package name from package name type' do + let(:package_name) do + case package_name_type + when :scoped_naming_convention + "@#{group.path}/scoped-package" + when :non_existing + 'non-existing-package' + end end end diff --git a/spec/support/shared_examples/controllers/unique_hll_events_examples.rb b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb index 3f69923028c..842ad89bafd 100644 --- a/spec/support/shared_examples/controllers/unique_hll_events_examples.rb +++ b/spec/support/shared_examples/controllers/unique_hll_events_examples.rb @@ -5,7 +5,7 @@ # - expected_type # - target_id -RSpec.shared_examples 'tracking unique hll events' do |feature_flag| +RSpec.shared_examples 'tracking unique hll events' do it 'tracks unique event' do expect(Gitlab::UsageDataCounters::HLLRedisCounter).to( receive(:track_event) @@ -15,14 +15,4 @@ RSpec.shared_examples 'tracking unique hll events' do |feature_flag| request end - - context 'when feature flag is disabled' do - it 'does not track unique event' do - stub_feature_flags(feature_flag => false) - - expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event) - - request - end - end end diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb index dcbf494186a..0a040557ffe 100644 --- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb +++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb @@ -218,7 +218,7 @@ RSpec.shared_examples 'wiki controller actions' do end context 'page view tracking' do - it_behaves_like 'tracking unique hll events', :track_unique_wiki_page_views do + it_behaves_like 'tracking unique hll events' do let(:target_id) { 'wiki_action' } let(:expected_type) { instance_of(String) } end diff --git a/spec/support/shared_examples/requests/api/merge_requests_shared_examples.rb b/spec/support/shared_examples/requests/api/merge_requests_shared_examples.rb new file mode 100644 index 00000000000..e6f9e5a434c --- /dev/null +++ b/spec/support/shared_examples/requests/api/merge_requests_shared_examples.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'rejects user from accessing merge request info' do + let(:project) { create(:project, :private) } + let(:merge_request) do + create(:merge_request, + author: user, + source_project: project, + target_project: project + ) + end + + before do + project.add_guest(user) + end + + it 'returns a 404 error' do + get api(url, user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Merge Request Not Found') + end +end diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb index d3ad7aa0595..be051dcbb7b 100644 --- a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb @@ -1,270 +1,430 @@ # frozen_string_literal: true -RSpec.shared_examples 'handling get metadata requests' do +RSpec.shared_examples 'handling get metadata requests' do |scope: :project| + using RSpec::Parameterized::TableSyntax + let_it_be(:package_dependency_link1) { create(:packages_dependency_link, package: package, dependency_type: :dependencies) } let_it_be(:package_dependency_link2) { create(:packages_dependency_link, package: package, dependency_type: :devDependencies) } let_it_be(:package_dependency_link3) { create(:packages_dependency_link, package: package, dependency_type: :bundleDependencies) } let_it_be(:package_dependency_link4) { create(:packages_dependency_link, package: package, dependency_type: :peerDependencies) } - let(:params) { {} } let(:headers) { {} } - subject { get(url, params: params, headers: headers) } + subject { get(url, headers: headers) } - shared_examples 'returning the npm package info' do - it 'returns the package info' do + shared_examples 'accept metadata request' do |status:| + it 'accepts the metadata request' do subject - expect_a_valid_package_response + expect(response).to have_gitlab_http_status(status) + expect(response.media_type).to eq('application/json') + expect(response).to match_response_schema('public_api/v4/packages/npm_package') + expect(json_response['name']).to eq(package.name) + expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version') + ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any + end + expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags') end end - shared_examples 'a package that requires auth' do - it 'denies request without oauth token' do + shared_examples 'reject metadata request' do |status:| + it 'rejects the metadata request' do subject - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(status) end + end - context 'with oauth token' do - let(:params) { { access_token: token.token } } - - it 'returns the package info with oauth token' do - subject + shared_examples 'redirect metadata request' do |status:| + it 'redirects metadata request' do + subject - expect_a_valid_package_response - end + expect(response).to have_gitlab_http_status(:found) + expect(response.headers['Location']).to eq("https://registry.npmjs.org/#{package_name}") end + end - context 'with job token' do - let(:params) { { job_token: job.token } } - - it 'returns the package info with running job token' do - subject + where(:auth, :package_name_type, :request_forward, :visibility, :user_role, :expected_result, :expected_status) do + nil | :scoped_naming_convention | true | 'PUBLIC' | nil | :accept | :ok + nil | :scoped_naming_convention | false | 'PUBLIC' | nil | :accept | :ok + nil | :non_existing | true | 'PUBLIC' | nil | :redirect | :redirected + nil | :non_existing | false | 'PUBLIC' | nil | :reject | :not_found + nil | :scoped_naming_convention | true | 'PRIVATE' | nil | :reject | :not_found + nil | :scoped_naming_convention | false | 'PRIVATE' | nil | :reject | :not_found + nil | :non_existing | true | 'PRIVATE' | nil | :redirect | :redirected + nil | :non_existing | false | 'PRIVATE' | nil | :reject | :not_found + nil | :scoped_naming_convention | true | 'INTERNAL' | nil | :reject | :not_found + nil | :scoped_naming_convention | false | 'INTERNAL' | nil | :reject | :not_found + nil | :non_existing | true | 'INTERNAL' | nil | :redirect | :redirected + nil | :non_existing | false | 'INTERNAL' | nil | :reject | :not_found + + :oauth | :scoped_naming_convention | true | 'PUBLIC' | :guest | :accept | :ok + :oauth | :scoped_naming_convention | true | 'PUBLIC' | :reporter | :accept | :ok + :oauth | :scoped_naming_convention | false | 'PUBLIC' | :guest | :accept | :ok + :oauth | :scoped_naming_convention | false | 'PUBLIC' | :reporter | :accept | :ok + :oauth | :non_existing | true | 'PUBLIC' | :guest | :redirect | :redirected + :oauth | :non_existing | true | 'PUBLIC' | :reporter | :redirect | :redirected + :oauth | :non_existing | false | 'PUBLIC' | :guest | :reject | :not_found + :oauth | :non_existing | false | 'PUBLIC' | :reporter | :reject | :not_found + :oauth | :scoped_naming_convention | true | 'PRIVATE' | :guest | :reject | :forbidden + :oauth | :scoped_naming_convention | true | 'PRIVATE' | :reporter | :accept | :ok + :oauth | :scoped_naming_convention | false | 'PRIVATE' | :guest | :reject | :forbidden + :oauth | :scoped_naming_convention | false | 'PRIVATE' | :reporter | :accept | :ok + :oauth | :non_existing | true | 'PRIVATE' | :guest | :redirect | :redirected + :oauth | :non_existing | true | 'PRIVATE' | :reporter | :redirect | :redirected + :oauth | :non_existing | false | 'PRIVATE' | :guest | :reject | :forbidden + :oauth | :non_existing | false | 'PRIVATE' | :reporter | :reject | :not_found + :oauth | :scoped_naming_convention | true | 'INTERNAL' | :guest | :accept | :ok + :oauth | :scoped_naming_convention | true | 'INTERNAL' | :reporter | :accept | :ok + :oauth | :scoped_naming_convention | false | 'INTERNAL' | :guest | :accept | :ok + :oauth | :scoped_naming_convention | false | 'INTERNAL' | :reporter | :accept | :ok + :oauth | :non_existing | true | 'INTERNAL' | :guest | :redirect | :redirected + :oauth | :non_existing | true | 'INTERNAL' | :reporter | :redirect | :redirected + :oauth | :non_existing | false | 'INTERNAL' | :guest | :reject | :not_found + :oauth | :non_existing | false | 'INTERNAL' | :reporter | :reject | :not_found + + :personal_access_token | :scoped_naming_convention | true | 'PUBLIC' | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | true | 'PUBLIC' | :reporter | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | 'PUBLIC' | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | 'PUBLIC' | :reporter | :accept | :ok + :personal_access_token | :non_existing | true | 'PUBLIC' | :guest | :redirect | :redirected + :personal_access_token | :non_existing | true | 'PUBLIC' | :reporter | :redirect | :redirected + :personal_access_token | :non_existing | false | 'PUBLIC' | :guest | :reject | :not_found + :personal_access_token | :non_existing | false | 'PUBLIC' | :reporter | :reject | :not_found + :personal_access_token | :scoped_naming_convention | true | 'PRIVATE' | :guest | :reject | :forbidden + :personal_access_token | :scoped_naming_convention | true | 'PRIVATE' | :reporter | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | 'PRIVATE' | :guest | :reject | :forbidden + :personal_access_token | :scoped_naming_convention | false | 'PRIVATE' | :reporter | :accept | :ok + :personal_access_token | :non_existing | true | 'PRIVATE' | :guest | :redirect | :redirected + :personal_access_token | :non_existing | true | 'PRIVATE' | :reporter | :redirect | :redirected + :personal_access_token | :non_existing | false | 'PRIVATE' | :guest | :reject | :forbidden + :personal_access_token | :non_existing | false | 'PRIVATE' | :reporter | :reject | :not_found + :personal_access_token | :scoped_naming_convention | true | 'INTERNAL' | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | true | 'INTERNAL' | :reporter | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | 'INTERNAL' | :guest | :accept | :ok + :personal_access_token | :scoped_naming_convention | false | 'INTERNAL' | :reporter | :accept | :ok + :personal_access_token | :non_existing | true | 'INTERNAL' | :guest | :redirect | :redirected + :personal_access_token | :non_existing | true | 'INTERNAL' | :reporter | :redirect | :redirected + :personal_access_token | :non_existing | false | 'INTERNAL' | :guest | :reject | :not_found + :personal_access_token | :non_existing | false | 'INTERNAL' | :reporter | :reject | :not_found + + :job_token | :scoped_naming_convention | true | 'PUBLIC' | :developer | :accept | :ok + :job_token | :scoped_naming_convention | false | 'PUBLIC' | :developer | :accept | :ok + :job_token | :non_existing | true | 'PUBLIC' | :developer | :redirect | :redirected + :job_token | :non_existing | false | 'PUBLIC' | :developer | :reject | :not_found + :job_token | :scoped_naming_convention | true | 'PRIVATE' | :developer | :accept | :ok + :job_token | :scoped_naming_convention | false | 'PRIVATE' | :developer | :accept | :ok + :job_token | :non_existing | true | 'PRIVATE' | :developer | :redirect | :redirected + :job_token | :non_existing | false | 'PRIVATE' | :developer | :reject | :not_found + :job_token | :scoped_naming_convention | true | 'INTERNAL' | :developer | :accept | :ok + :job_token | :scoped_naming_convention | false | 'INTERNAL' | :developer | :accept | :ok + :job_token | :non_existing | true | 'INTERNAL' | :developer | :redirect | :redirected + :job_token | :non_existing | false | 'INTERNAL' | :developer | :reject | :not_found + + :deploy_token | :scoped_naming_convention | true | 'PUBLIC' | nil | :accept | :ok + :deploy_token | :scoped_naming_convention | false | 'PUBLIC' | nil | :accept | :ok + :deploy_token | :non_existing | true | 'PUBLIC' | nil | :redirect | :redirected + :deploy_token | :non_existing | false | 'PUBLIC' | nil | :reject | :not_found + :deploy_token | :scoped_naming_convention | true | 'PRIVATE' | nil | :accept | :ok + :deploy_token | :scoped_naming_convention | false | 'PRIVATE' | nil | :accept | :ok + :deploy_token | :non_existing | true | 'PRIVATE' | nil | :redirect | :redirected + :deploy_token | :non_existing | false | 'PRIVATE' | nil | :reject | :not_found + :deploy_token | :scoped_naming_convention | true | 'INTERNAL' | nil | :accept | :ok + :deploy_token | :scoped_naming_convention | false | 'INTERNAL' | nil | :accept | :ok + :deploy_token | :non_existing | true | 'INTERNAL' | nil | :redirect | :redirected + :deploy_token | :non_existing | false | 'INTERNAL' | nil | :reject | :not_found + end - expect_a_valid_package_response + with_them do + include_context 'set package name from package name type' + + let(:headers) do + case auth + when :oauth + build_token_auth_header(token.token) + when :personal_access_token + build_token_auth_header(personal_access_token.token) + when :job_token + build_token_auth_header(job.token) + when :deploy_token + build_token_auth_header(deploy_token.token) + else + {} end + end - it 'denies request without running job token' do - job.update!(status: :success) + before do + project.send("add_#{user_role}", user) if user_role + project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + package.update!(name: package_name) unless package_name == 'non-existing-package' + stub_application_setting(npm_package_requests_forwarding: request_forward) + end - subject + example_name = "#{params[:expected_result]} metadata request" + status = params[:expected_status] - expect(response).to have_gitlab_http_status(:unauthorized) + if scope == :instance && params[:package_name_type] != :scoped_naming_convention + if params[:request_forward] + example_name = 'redirect metadata request' + status = :redirected + else + example_name = 'reject metadata request' + status = :not_found end end - context 'with deploy token' do - let(:headers) { build_token_auth_header(deploy_token.token) } + it_behaves_like example_name, status: status + end - it 'returns the package info with deploy token' do - subject + context 'with a developer' do + let(:headers) { build_token_auth_header(personal_access_token.token) } - expect_a_valid_package_response - end + before do + project.add_developer(user) end - end - - context 'a public project' do - it_behaves_like 'returning the npm package info' context 'project path with a dot' do before do project.update!(path: 'foo.bar') end - it_behaves_like 'returning the npm package info' + it_behaves_like 'accept metadata request', status: :ok end - context 'with request forward disabled' do + context 'with a job token' do + let(:headers) { build_token_auth_header(job.token) } + before do - stub_application_setting(npm_package_requests_forwarding: false) + job.update!(status: :success) end - it_behaves_like 'returning the npm package info' + it_behaves_like 'reject metadata request', status: :unauthorized + end + end +end + +RSpec.shared_examples 'handling get dist tags requests' do |scope: :project| + using RSpec::Parameterized::TableSyntax + include_context 'set package name from package name type' - context 'with unknown package' do - let(:package_name) { 'unknown' } + let_it_be(:package_tag1) { create(:packages_tag, package: package) } + let_it_be(:package_tag2) { create(:packages_tag, package: package) } - it 'returns the proper response' do - subject + let(:headers) { {} } - expect(response).to have_gitlab_http_status(:not_found) - end - end + subject { get(url, headers: headers) } + + shared_examples 'reject package tags request' do |status:| + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' end - context 'with request forward enabled' do - before do - stub_application_setting(npm_package_requests_forwarding: true) - end + it_behaves_like 'returning response status', status + end - it_behaves_like 'returning the npm package info' + shared_examples 'handling different package names, visibilities and user roles' do + where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do + :scoped_naming_convention | 'PUBLIC' | :anonymous | :accept | :ok + :scoped_naming_convention | 'PUBLIC' | :guest | :accept | :ok + :scoped_naming_convention | 'PUBLIC' | :reporter | :accept | :ok + :non_existing | 'PUBLIC' | :anonymous | :reject | :not_found + :non_existing | 'PUBLIC' | :guest | :reject | :not_found + :non_existing | 'PUBLIC' | :reporter | :reject | :not_found + + :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found + :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden + :scoped_naming_convention | 'PRIVATE' | :reporter | :accept | :ok + :non_existing | 'PRIVATE' | :anonymous | :reject | :not_found + :non_existing | 'PRIVATE' | :guest | :reject | :forbidden + :non_existing | 'PRIVATE' | :reporter | :reject | :not_found + + :scoped_naming_convention | 'INTERNAL' | :anonymous | :reject | :not_found + :scoped_naming_convention | 'INTERNAL' | :guest | :accept | :ok + :scoped_naming_convention | 'INTERNAL' | :reporter | :accept | :ok + :non_existing | 'INTERNAL' | :anonymous | :reject | :not_found + :non_existing | 'INTERNAL' | :guest | :reject | :not_found + :non_existing | 'INTERNAL' | :reporter | :reject | :not_found + end + + with_them do + let(:anonymous) { user_role == :anonymous } - context 'with unknown package' do - let(:package_name) { 'unknown' } + subject { get(url, headers: anonymous ? {} : headers) } - it 'returns a redirect' do - subject + before do + project.send("add_#{user_role}", user) unless anonymous + project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + end - expect(response).to have_gitlab_http_status(:found) - expect(response.headers['Location']).to eq('https://registry.npmjs.org/unknown') - end + example_name = "#{params[:expected_result]} package tags request" + status = params[:expected_status] - it_behaves_like 'a gitlab tracking event', described_class.name, 'npm_request_forward' + if scope == :instance && params[:package_name_type] != :scoped_naming_convention + example_name = 'reject package tags request' + status = :not_found end + + it_behaves_like example_name, status: status end end - context 'internal project' do - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) - end + context 'with oauth token' do + let(:headers) { build_token_auth_header(token.token) } - it_behaves_like 'a package that requires auth' + it_behaves_like 'handling different package names, visibilities and user roles' end - context 'private project' do - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - end + context 'with personal access token' do + let(:headers) { build_token_auth_header(personal_access_token.token) } - it_behaves_like 'a package that requires auth' + it_behaves_like 'handling different package names, visibilities and user roles' + end +end - context 'with guest' do - let(:params) { { access_token: token.token } } +RSpec.shared_examples 'handling create dist tag requests' do |scope: :project| + using RSpec::Parameterized::TableSyntax + include_context 'set package name from package name type' - it 'denies request when not enough permissions' do - project.add_guest(user) + let_it_be(:tag_name) { 'test' } - subject + let(:params) { {} } + let(:version) { package.version } + let(:env) { { 'api.request.body': version } } + let(:headers) { {} } - expect(response).to have_gitlab_http_status(:forbidden) - end + shared_examples 'reject create package tag request' do |status:| + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' end + + it_behaves_like 'returning response status', status end - def expect_a_valid_package_response - expect(response).to have_gitlab_http_status(:ok) - expect(response.media_type).to eq('application/json') - expect(response).to match_response_schema('public_api/v4/packages/npm_package') - expect(json_response['name']).to eq(package.name) - expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version') - ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| - expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any + shared_examples 'handling different package names, visibilities and user roles' do + where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do + :scoped_naming_convention | 'PUBLIC' | :anonymous | :reject | :forbidden + :scoped_naming_convention | 'PUBLIC' | :guest | :reject | :forbidden + :scoped_naming_convention | 'PUBLIC' | :developer | :accept | :ok + :non_existing | 'PUBLIC' | :anonymous | :reject | :forbidden + :non_existing | 'PUBLIC' | :guest | :reject | :forbidden + :non_existing | 'PUBLIC' | :developer | :reject | :not_found + + :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found + :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden + :scoped_naming_convention | 'PRIVATE' | :developer | :accept | :ok + :non_existing | 'PRIVATE' | :anonymous | :reject | :not_found + :non_existing | 'PRIVATE' | :guest | :reject | :forbidden + :non_existing | 'PRIVATE' | :developer | :reject | :not_found + + :scoped_naming_convention | 'INTERNAL' | :anonymous | :reject | :forbidden + :scoped_naming_convention | 'INTERNAL' | :guest | :reject | :forbidden + :scoped_naming_convention | 'INTERNAL' | :developer | :accept | :ok + :non_existing | 'INTERNAL' | :anonymous | :reject | :forbidden + :non_existing | 'INTERNAL' | :guest | :reject | :forbidden + :non_existing | 'INTERNAL' | :developer | :reject | :not_found end - expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags') - end -end -RSpec.shared_examples 'handling get dist tags requests' do - let_it_be(:package_tag1) { create(:packages_tag, package: package) } - let_it_be(:package_tag2) { create(:packages_tag, package: package) } + with_them do + let(:anonymous) { user_role == :anonymous } - let(:params) { {} } + subject { put(url, env: env, headers: headers) } - subject { get(url, params: params) } + before do + project.send("add_#{user_role}", user) unless anonymous + project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + end - context 'with public project' do - context 'with authenticated user' do - let(:params) { { private_token: personal_access_token.token } } + example_name = "#{params[:expected_result]} create package tag request" + status = params[:expected_status] - it_behaves_like 'returns package tags', :maintainer - it_behaves_like 'returns package tags', :developer - it_behaves_like 'returns package tags', :reporter - it_behaves_like 'returns package tags', :guest - end + if scope == :instance && params[:package_name_type] != :scoped_naming_convention + example_name = 'reject create package tag request' + status = :not_found + end - context 'with unauthenticated user' do - it_behaves_like 'returns package tags', :no_type + it_behaves_like example_name, status: status end end - context 'with private project' do - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - end + context 'with oauth token' do + let(:headers) { build_token_auth_header(token.token) } - context 'with authenticated user' do - let(:params) { { private_token: personal_access_token.token } } + it_behaves_like 'handling different package names, visibilities and user roles' + end - it_behaves_like 'returns package tags', :maintainer - it_behaves_like 'returns package tags', :developer - it_behaves_like 'returns package tags', :reporter - it_behaves_like 'rejects package tags access', :guest, :forbidden - end + context 'with personal access token' do + let(:headers) { build_token_auth_header(personal_access_token.token) } - context 'with unauthenticated user' do - it_behaves_like 'rejects package tags access', :no_type, :not_found - end + it_behaves_like 'handling different package names, visibilities and user roles' end end -RSpec.shared_examples 'handling create dist tag requests' do - let_it_be(:tag_name) { 'test' } +RSpec.shared_examples 'handling delete dist tag requests' do |scope: :project| + using RSpec::Parameterized::TableSyntax + include_context 'set package name from package name type' - let(:params) { {} } - let(:env) { {} } - let(:version) { package.version } - - subject { put(url, env: env, params: params) } + let_it_be(:package_tag) { create(:packages_tag, package: package) } - context 'with public project' do - context 'with authenticated user' do - let(:params) { { private_token: personal_access_token.token } } - let(:env) { { 'api.request.body': version } } + let(:tag_name) { package_tag.name } + let(:headers) { {} } - it_behaves_like 'create package tag', :maintainer - it_behaves_like 'create package tag', :developer - it_behaves_like 'rejects package tags access', :reporter, :forbidden - it_behaves_like 'rejects package tags access', :guest, :forbidden + shared_examples 'reject delete package tag request' do |status:| + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' end - context 'with unauthenticated user' do - it_behaves_like 'rejects package tags access', :no_type, :unauthorized - end + it_behaves_like 'returning response status', status end -end -RSpec.shared_examples 'handling delete dist tag requests' do - let_it_be(:package_tag) { create(:packages_tag, package: package) } + shared_examples 'handling different package names, visibilities and user roles' do + where(:package_name_type, :visibility, :user_role, :expected_result, :expected_status) do + :scoped_naming_convention | 'PUBLIC' | :anonymous | :reject | :forbidden + :scoped_naming_convention | 'PUBLIC' | :guest | :reject | :forbidden + :scoped_naming_convention | 'PUBLIC' | :maintainer | :accept | :ok + :non_existing | 'PUBLIC' | :anonymous | :reject | :forbidden + :non_existing | 'PUBLIC' | :guest | :reject | :forbidden + :non_existing | 'PUBLIC' | :maintainer | :reject | :not_found + + :scoped_naming_convention | 'PRIVATE' | :anonymous | :reject | :not_found + :scoped_naming_convention | 'PRIVATE' | :guest | :reject | :forbidden + :scoped_naming_convention | 'PRIVATE' | :maintainer | :accept | :ok + :non_existing | 'INTERNAL' | :anonymous | :reject | :forbidden + :non_existing | 'INTERNAL' | :guest | :reject | :forbidden + :non_existing | 'INTERNAL' | :maintainer | :reject | :not_found + end - let(:params) { {} } - let(:tag_name) { package_tag.name } + with_them do + let(:anonymous) { user_role == :anonymous } + + subject { delete(url, headers: headers) } - subject { delete(url, params: params) } + before do + project.send("add_#{user_role}", user) unless anonymous + project.update!(visibility: Gitlab::VisibilityLevel.const_get(visibility, false)) + end - context 'with public project' do - context 'with authenticated user' do - let(:params) { { private_token: personal_access_token.token } } + example_name = "#{params[:expected_result]} delete package tag request" + status = params[:expected_status] - it_behaves_like 'delete package tag', :maintainer - it_behaves_like 'rejects package tags access', :developer, :forbidden - it_behaves_like 'rejects package tags access', :reporter, :forbidden - it_behaves_like 'rejects package tags access', :guest, :forbidden - end + if scope == :instance && params[:package_name_type] != :scoped_naming_convention + example_name = 'reject delete package tag request' + status = :not_found + end - context 'with unauthenticated user' do - it_behaves_like 'rejects package tags access', :no_type, :unauthorized + it_behaves_like example_name, status: status end end - context 'with private project' do - before do - project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - end + context 'with oauth token' do + let(:headers) { build_token_auth_header(token.token) } - context 'with authenticated user' do - let(:params) { { private_token: personal_access_token.token } } + it_behaves_like 'handling different package names, visibilities and user roles' + end - it_behaves_like 'delete package tag', :maintainer - it_behaves_like 'rejects package tags access', :developer, :forbidden - it_behaves_like 'rejects package tags access', :reporter, :forbidden - it_behaves_like 'rejects package tags access', :guest, :forbidden - end + context 'with personal access token' do + let(:headers) { build_token_auth_header(personal_access_token.token) } - context 'with unauthenticated user' do - it_behaves_like 'rejects package tags access', :no_type, :unauthorized - end + it_behaves_like 'handling different package names, visibilities and user roles' end end diff --git a/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb new file mode 100644 index 00000000000..e6b3dc74b74 --- /dev/null +++ b/spec/support/shared_examples/requests/api/npm_packages_tags_shared_examples.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'rejects package tags access' do |status:| + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' + end + + it_behaves_like 'returning response status', status +end + +RSpec.shared_examples 'accept package tags request' do |status:| + using RSpec::Parameterized::TableSyntax + + before do + stub_application_setting(npm_package_requests_forwarding: false) + end + + context 'with valid package name' do + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' + end + + it_behaves_like 'returning response status', status + + it 'returns a valid json response' do + subject + + expect(response.media_type).to eq('application/json') + expect(json_response).to be_a(Hash) + end + + it 'returns two package tags' do + subject + + expect(json_response).to match_schema('public_api/v4/packages/npm_package_tags') + expect(json_response.length).to eq(3) # two tags + latest (auto added) + expect(json_response[package_tag1.name]).to eq(package.version) + expect(json_response[package_tag2.name]).to eq(package.version) + expect(json_response['latest']).to eq(package.version) + end + end + + context 'with invalid package name' do + where(:package_name, :status) do + '%20' | :bad_request + nil | :not_found + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end +end + +RSpec.shared_examples 'accept create package tag request' do |user_type| + using RSpec::Parameterized::TableSyntax + + context 'with valid package name' do + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' + end + + it_behaves_like 'returning response status', :no_content + + it 'creates the package tag' do + expect { subject }.to change { Packages::Tag.count }.by(1) + + last_tag = Packages::Tag.last + expect(last_tag.name).to eq(tag_name) + expect(last_tag.package).to eq(package) + end + + it 'returns a valid response' do + subject + + expect(response.body).to be_empty + end + + context 'with already existing tag' do + let_it_be(:package2) { create(:npm_package, project: project, name: package.name, version: '5.5.55') } + let_it_be(:tag) { create(:packages_tag, package: package2, name: tag_name) } + + it_behaves_like 'returning response status', :no_content + + it 'reuses existing tag' do + expect(package.tags).to be_empty + expect(package2.tags).to eq([tag]) + expect { subject }.to not_change { Packages::Tag.count } + expect(package.reload.tags).to eq([tag]) + expect(package2.reload.tags).to be_empty + end + + it 'returns a valid response' do + subject + + expect(response.body).to be_empty + end + end + end + + context 'with invalid package name' do + where(:package_name, :status) do + 'unknown' | :not_found + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + + context 'with invalid tag name' do + where(:tag_name, :status) do + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + + context 'with invalid version' do + where(:version, :status) do + ' ' | :bad_request + '' | :bad_request + nil | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end +end + +RSpec.shared_examples 'accept delete package tag request' do |user_type| + using RSpec::Parameterized::TableSyntax + + context 'with valid package name' do + before do + package.update!(name: package_name) unless package_name == 'non-existing-package' + end + + it_behaves_like 'returning response status', :no_content + + it 'returns a valid response' do + subject + + expect(response.body).to be_empty + end + + it 'destroy the package tag' do + expect(package.tags).to eq([package_tag]) + expect { subject }.to change { Packages::Tag.count }.by(-1) + expect(package.reload.tags).to be_empty + end + + context 'with tag from other package' do + let(:package2) { create(:npm_package, project: project) } + let(:package_tag) { create(:packages_tag, package: package2) } + + it_behaves_like 'returning response status', :not_found + end + end + + context 'with invalid package name' do + where(:package_name, :status) do + 'unknown' | :not_found + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + + context 'with invalid tag name' do + where(:tag_name, :status) do + 'unknown' | :not_found + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end +end diff --git a/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb deleted file mode 100644 index 2c203dc096e..00000000000 --- a/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb +++ /dev/null @@ -1,185 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'rejects package tags access' do |user_type, status| - context "for user type #{user_type}" do - before do - project.send("add_#{user_type}", user) unless user_type == :no_type - end - - it_behaves_like 'returning response status', status - end -end - -RSpec.shared_examples 'returns package tags' do |user_type| - using RSpec::Parameterized::TableSyntax - - before do - stub_application_setting(npm_package_requests_forwarding: false) - project.send("add_#{user_type}", user) unless user_type == :no_type - end - - it_behaves_like 'returning response status', :success - - it 'returns a valid json response' do - subject - - expect(response.media_type).to eq('application/json') - expect(json_response).to be_a(Hash) - end - - it 'returns two package tags' do - subject - - expect(json_response).to match_schema('public_api/v4/packages/npm_package_tags') - expect(json_response.length).to eq(3) # two tags + latest (auto added) - expect(json_response[package_tag1.name]).to eq(package.version) - expect(json_response[package_tag2.name]).to eq(package.version) - expect(json_response['latest']).to eq(package.version) - end - - context 'with invalid package name' do - where(:package_name, :status) do - '%20' | :bad_request - nil | :not_found - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end -end - -RSpec.shared_examples 'create package tag' do |user_type| - using RSpec::Parameterized::TableSyntax - - before do - project.send("add_#{user_type}", user) unless user_type == :no_type - end - - it_behaves_like 'returning response status', :no_content - - it 'creates the package tag' do - expect { subject }.to change { Packages::Tag.count }.by(1) - - last_tag = Packages::Tag.last - expect(last_tag.name).to eq(tag_name) - expect(last_tag.package).to eq(package) - end - - it 'returns a valid response' do - subject - - expect(response.body).to be_empty - end - - context 'with already existing tag' do - let_it_be(:package2) { create(:npm_package, project: project, name: package.name, version: '5.5.55') } - let_it_be(:tag) { create(:packages_tag, package: package2, name: tag_name) } - - it_behaves_like 'returning response status', :no_content - - it 'reuses existing tag' do - expect(package.tags).to be_empty - expect(package2.tags).to eq([tag]) - expect { subject }.to not_change { Packages::Tag.count } - expect(package.reload.tags).to eq([tag]) - expect(package2.reload.tags).to be_empty - end - - it 'returns a valid response' do - subject - - expect(response.body).to be_empty - end - end - - context 'with invalid package name' do - where(:package_name, :status) do - 'unknown' | :not_found - '' | :not_found - '%20' | :bad_request - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end - - context 'with invalid tag name' do - where(:tag_name, :status) do - '' | :not_found - '%20' | :bad_request - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end - - context 'with invalid version' do - where(:version, :status) do - ' ' | :bad_request - '' | :bad_request - nil | :bad_request - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end -end - -RSpec.shared_examples 'delete package tag' do |user_type| - using RSpec::Parameterized::TableSyntax - - before do - project.send("add_#{user_type}", user) unless user_type == :no_type - end - - context "for #{user_type} user" do - it_behaves_like 'returning response status', :no_content - - it 'returns a valid response' do - subject - - expect(response.body).to be_empty - end - - it 'destroy the package tag' do - expect(package.tags).to eq([package_tag]) - expect { subject }.to change { Packages::Tag.count }.by(-1) - expect(package.reload.tags).to be_empty - end - - context 'with tag from other package' do - let(:package2) { create(:npm_package, project: project) } - let(:package_tag) { create(:packages_tag, package: package2) } - - it_behaves_like 'returning response status', :not_found - end - - context 'with invalid package name' do - where(:package_name, :status) do - 'unknown' | :not_found - '' | :not_found - '%20' | :bad_request - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end - - context 'with invalid tag name' do - where(:tag_name, :status) do - 'unknown' | :not_found - '' | :not_found - '%20' | :bad_request - end - - with_them do - it_behaves_like 'returning response status', params[:status] - end - end - end -end |