Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-01-19 12:10:46 +00:00
parent fcef382cb9
commit 442a79b733
96 changed files with 2480 additions and 1591 deletions

View File

@ -1 +1 @@
64625df11e8add7e64cce44a47984512e5f42d72
c89fdf6bb2dc9f652f5c724caf13d3bde76e9d90

View File

@ -442,10 +442,10 @@ const Api = {
});
},
applySuggestion(id) {
applySuggestion(id, message) {
const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
return axios.put(url);
return axios.put(url, { commit_message: message });
},
applySuggestionBatch(ids) {

View File

@ -124,6 +124,11 @@ export default {
required: false,
default: false,
},
defaultSuggestionCommitMessage: {
type: String,
required: false,
default: '',
},
mrReviews: {
type: Object,
required: false,
@ -268,6 +273,7 @@ export default {
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference),
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
mrReviews: this.mrReviews || {},
});

View File

@ -83,6 +83,7 @@ export default function initDiffsApp(store) {
showSuggestPopover: parseBoolean(dataset.showSuggestPopover),
showWhitespaceDefault: parseBoolean(dataset.showWhitespaceDefault),
viewDiffsFileByFile: parseBoolean(dataset.fileByFileDefault),
defaultSuggestionCommitMessage: dataset.defaultSuggestionCommitMessage,
};
},
computed: {
@ -123,6 +124,7 @@ export default function initDiffsApp(store) {
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
fileByFileUserPreference: this.viewDiffsFileByFile,
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
mrReviews: getReviewsForMergeRequest(mrPath),
},
});

View File

@ -62,6 +62,7 @@ export const setBaseConfig = ({ commit }, options) => {
projectPath,
dismissEndpoint,
showSuggestPopover,
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
} = options;
@ -73,6 +74,7 @@ export const setBaseConfig = ({ commit }, options) => {
projectPath,
dismissEndpoint,
showSuggestPopover,
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
});

View File

@ -45,5 +45,6 @@ export default () => ({
fileFinderVisible: false,
dismissEndpoint: '',
showSuggestPopover: true,
defaultSuggestionCommitMessage: '',
mrReviews: {},
});

View File

@ -36,6 +36,7 @@ export default {
projectPath,
dismissEndpoint,
showSuggestPopover,
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
} = options;
@ -47,6 +48,7 @@ export default {
projectPath,
dismissEndpoint,
showSuggestPopover,
defaultSuggestionCommitMessage,
viewDiffsFileByFile,
mrReviews,
});

View File

@ -54,6 +54,7 @@ export default {
...mapState({
batchSuggestionsInfo: (state) => state.notes.batchSuggestionsInfo,
}),
...mapState('diffs', ['defaultSuggestionCommitMessage']),
noteBody() {
return this.note.note;
},
@ -98,12 +99,16 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
applySuggestion({ suggestionId, flashContainer, callback = () => {} }) {
applySuggestion({ suggestionId, flashContainer, callback = () => {}, message }) {
const { discussion_id: discussionId, id: noteId } = this.note;
return this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer }).then(
callback,
);
return this.submitSuggestion({
discussionId,
noteId,
suggestionId,
flashContainer,
message,
}).then(callback);
},
applySuggestionBatch({ flashContainer }) {
return this.submitSuggestionBatch({ flashContainer });
@ -130,6 +135,7 @@ export default {
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
:default-commit-message="defaultSuggestionCommitMessage"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"
@addToBatch="addSuggestionToBatch"

View File

@ -559,7 +559,7 @@ export const updateResolvableDiscussionsCounts = ({ commit }) =>
export const submitSuggestion = (
{ commit, dispatch },
{ discussionId, suggestionId, flashContainer },
{ discussionId, suggestionId, flashContainer, message },
) => {
const dispatchResolveDiscussion = () =>
dispatch('resolveDiscussion', { discussionId }).catch(() => {});
@ -567,7 +567,7 @@ export const submitSuggestion = (
commit(types.SET_RESOLVING_DISCUSSION, true);
dispatch('stopPolling');
return Api.applySuggestion(suggestionId)
return Api.applySuggestion(suggestionId, message)
.then(dispatchResolveDiscussion)
.catch((err) => {
const defaultMessage = __(

View File

@ -1,8 +1,8 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { __ } from '~/locale';
import { DEFAULT, LOAD_FAILURE } from '../../constants';
import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import PipelineGraph from './graph_component.vue';
import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils';

View File

@ -1,5 +1,5 @@
<script>
import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import LinkedPipeline from './linked_pipeline.vue';
import { LOAD_FAILURE } from '../../constants';
import { UPSTREAM } from './constants';

View File

@ -1,17 +0,0 @@
fragment LinkedPipelineData on Pipeline {
id
iid
path
status: detailedStatus {
group
label
icon
}
sourceJob {
name
}
project {
name
fullPath
}
}

View File

@ -36,7 +36,7 @@ export default {
</script>
<template>
<div class="m-3 ml-7" :class="messageClass">
<div class="gl-m-3 gl-ml-7" :class="messageClass">
<slot></slot>
<gl-link v-if="helpPath" :href="helpPath" target="_blank">
<gl-icon :size="16" name="question-o" class="align-middle" />

View File

@ -30,7 +30,7 @@ export default {
};
</script>
<template>
<section class="mr-widget-help font-italic">
<section class="gl-py-3 gl-pr-3 gl-pl-5 gl-ml-7 mr-widget-help gl-font-style-italic">
<template v-if="missingBranch">
{{ missingBranchInfo }}
</template>

View File

@ -30,7 +30,7 @@ export default {
};
</script>
<template>
<section class="mr-info-list mr-links">
<section class="mr-info-list gl-ml-7 gl-pb-5">
<p v-if="relatedLinks.closing">{{ closesText }} <span v-html="relatedLinks.closing"></span></p>
<p v-if="relatedLinks.mentioned">
{{ s__('mrWidget|Mentions') }} <span v-html="relatedLinks.mentioned"></span>

View File

@ -18,7 +18,7 @@ export default {
</script>
<template>
<p v-once class="mr-info-list mr-links gl-mb-0">
<p v-once class="mr-info-list gl-ml-7 gl-pb-5 gl-mb-0">
<span class="status-text">
<gl-sprintf :message="$options.i18n.removesBranchText">
<template #strong="{ content }">

View File

@ -83,6 +83,7 @@ export default {
:aria-label="ariaLabel"
category="tertiary"
class="commit-edit-toggle gl-mr-3"
size="small"
:icon="collapseIcon"
@click.stop="toggle()"
/>

View File

@ -159,13 +159,13 @@ export default {
<div class="rebase-state-find-class-convention media media-body space-children">
<span
v-if="rebaseInProgress || isMakingRequest"
class="gl-font-weight-bold"
class="gl-font-weight-bold gl-ml-0!"
data-testid="rebase-message"
>{{ __('Rebase in progress') }}</span
>
<span
v-if="!rebaseInProgress && !canPushToSourceBranch"
class="gl-font-weight-bold"
class="gl-font-weight-bold gl-ml-0!"
data-testid="rebase-message"
v-html="fastForwardMergeText"
></span>
@ -181,12 +181,17 @@ export default {
>
{{ __('Rebase') }}
</gl-button>
<span v-if="!rebasingError" class="gl-font-weight-bold" data-testid="rebase-message">{{
__(
'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
)
}}</span>
<span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
<span
v-if="!rebasingError"
class="gl-font-weight-bold gl-ml-0!"
data-testid="rebase-message"
>{{
__(
'Fast-forward merge is not possible. Rebase the source branch onto the target branch.',
)
}}</span
>
<span v-else class="gl-font-weight-bold danger gl-ml-0!" data-testid="rebase-message">{{
rebasingError
}}</span>
</div>

View File

@ -26,7 +26,11 @@ export default () => {
registerExtension(issueExtension);
const vm = new Vue({ ...MrWidgetOptions, apolloProvider });
const vm = new Vue({
el: '#js-vue-mr-widget',
...MrWidgetOptions,
apolloProvider,
});
window.gl.mrWidget = {
checkStatus: vm.checkStatus,

View File

@ -48,7 +48,6 @@ import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grou
import getStateQuery from './queries/get_state.query.graphql';
export default {
el: '#js-vue-mr-widget',
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'MRWidget',

View File

@ -1,117 +0,0 @@
<script>
import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
const buttonVariants = ['danger', 'primary', 'success', 'warning'];
const sizeVariants = ['sm', 'md', 'lg', 'xl'];
export default {
name: 'DeprecatedModal2', // use GlModal instead
components: {
GlButton,
},
props: {
id: {
type: String,
required: false,
default: null,
},
modalSize: {
type: String,
required: false,
default: 'md',
validator: (value) => sizeVariants.includes(value),
},
headerTitleText: {
type: String,
required: false,
default: '',
},
footerPrimaryButtonVariant: {
type: String,
required: false,
default: 'primary',
validator: (value) => buttonVariants.includes(value),
},
footerPrimaryButtonText: {
type: String,
required: false,
default: '',
},
},
computed: {
modalSizeClass() {
return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`;
},
},
mounted() {
$(this.$el).on('shown.bs.modal', this.opened).on('hidden.bs.modal', this.closed);
},
beforeDestroy() {
$(this.$el).off('shown.bs.modal', this.opened).off('hidden.bs.modal', this.closed);
},
methods: {
emitCancel(event) {
this.$emit('cancel', event);
},
emitSubmit(event) {
this.$emit('submit', event);
},
opened() {
this.$emit('open');
},
closed() {
this.$emit('closed');
},
},
};
</script>
<template>
<div :id="id" class="modal fade" tabindex="-1" role="dialog">
<div :class="modalSizeClass" class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header gl-pr-4">
<slot name="header">
<h4 class="modal-title">
<slot name="title"> {{ headerTitleText }} </slot>
</h4>
<gl-button
:aria-label="s__('Modal|Close')"
variant="default"
category="tertiary"
size="small"
icon="close"
class="js-modal-close-action"
data-dismiss="modal"
@click="emitCancel($event)"
/>
</slot>
</div>
<div class="modal-body"><slot></slot></div>
<div class="modal-footer">
<slot name="footer">
<gl-button
class="js-modal-cancel-action qa-modal-cancel-button"
data-dismiss="modal"
@click="emitCancel($event)"
>
{{ s__('Modal|Cancel') }}
</gl-button>
<gl-button
:class="`btn-${footerPrimaryButtonVariant}`"
class="js-modal-primary-action qa-modal-primary-button"
data-dismiss="modal"
@click="emitSubmit($event)"
>
{{ footerPrimaryButtonText }}
</gl-button>
</slot>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,6 +1,5 @@
<script>
import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton },
@ -10,7 +9,7 @@ export default {
required: false,
default: false,
},
fileName: {
defaultCommitMessage: {
type: String,
required: true,
},
@ -18,18 +17,11 @@ export default {
data() {
return {
message: null,
buttonText: __('Apply suggestion'),
headerText: __('Apply suggestion commit message'),
};
},
computed: {
placeholderText() {
return sprintf(__('Apply suggestion on %{fileName}'), { fileName: this.fileName });
},
},
methods: {
onApply() {
this.$emit('apply', this.message || this.placeholderText);
this.$emit('apply', this.message);
},
},
};
@ -37,18 +29,26 @@ export default {
<template>
<gl-dropdown
:text="buttonText"
:header-text="headerText"
:text="__('Apply suggestion')"
:disabled="disabled"
boundary="window"
right
menu-class="gl-w-full! gl-pb-0!"
menu-class="gl-w-full!"
@shown="$refs.commitMessage.$el.focus()"
>
<gl-dropdown-form class="gl-m-3!">
<gl-form-textarea v-model="message" :placeholder="placeholderText" />
<gl-dropdown-form class="gl-px-4! gl-m-0!">
<label for="commit-message">{{ __('Commit message') }}</label>
<gl-form-textarea
id="commit-message"
ref="commitMessage"
v-model="message"
:placeholder="defaultCommitMessage"
submit-on-enter
@submit="onApply"
/>
<gl-button
class="gl-w-quarter! gl-mt-3 gl-text-center! float-right"
category="secondary"
class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right"
category="primary"
variant="success"
@click="onApply"
>

View File

@ -27,6 +27,10 @@ export default {
type: String,
required: true,
},
defaultCommitMessage: {
type: String,
required: true,
},
suggestionsCount: {
type: Number,
required: false,
@ -47,8 +51,8 @@ export default {
},
},
methods: {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
applySuggestion(callback, message) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback, message });
},
applySuggestionBatch() {
this.$emit('applyBatch');
@ -74,6 +78,7 @@ export default {
:is-applying-batch="suggestion.is_applying_batch"
:batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
:default-commit-message="defaultCommitMessage"
:inapplicable-reason="suggestion.inapplicable_reason"
@apply="applySuggestion"
@applyBatch="applySuggestionBatch"

View File

@ -2,9 +2,10 @@
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ApplySuggestion from './apply_suggestion.vue';
export default {
components: { GlIcon, GlButton, GlLoadingIcon },
components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
mixins: [glFeatureFlagsMixin()],
props: {
@ -37,6 +38,10 @@ export default {
type: String,
required: true,
},
defaultCommitMessage: {
type: String,
required: true,
},
inapplicableReason: {
type: String,
required: false,
@ -57,6 +62,9 @@ export default {
canBeBatched() {
return Boolean(this.glFeatures.batchSuggestions);
},
canAddCustomCommitMessage() {
return this.glFeatures.suggestionsCustomCommit;
},
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
@ -77,10 +85,10 @@ export default {
},
},
methods: {
applySuggestion() {
applySuggestion(message) {
if (!this.canApply) return;
this.isApplyingSingle = true;
this.$emit('apply', this.applySuggestionCallback);
this.$emit('apply', this.applySuggestionCallback, message);
},
applySuggestionCallback() {
this.isApplyingSingle = false;
@ -142,7 +150,14 @@ export default {
>
{{ __('Add suggestion to batch') }}
</gl-button>
<span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<apply-suggestion
v-if="canAddCustomCommitMessage"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
class="gl-ml-3"
@apply="applySuggestion"
/>
<span v-else v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
<gl-button
v-if="isLoggedIn"
class="btn-inverted js-apply-btn btn-grouped"

View File

@ -38,6 +38,10 @@ export default {
type: String,
required: true,
},
defaultCommitMessage: {
type: String,
required: true,
},
suggestionsCount: {
type: Number,
required: false,
@ -82,16 +86,30 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
const { suggestions, disabled, batchSuggestionsInfo, helpPagePath, suggestionsCount } = this;
const {
suggestions,
disabled,
batchSuggestionsInfo,
helpPagePath,
defaultCommitMessage,
suggestionsCount,
} = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath, suggestionsCount },
propsData: {
disabled,
suggestion,
batchSuggestionsInfo,
helpPagePath,
defaultCommitMessage,
suggestionsCount,
},
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
suggestionDiff.$on('apply', ({ suggestionId, callback, message }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el, message });
});
suggestionDiff.$on('applyBatch', () => {

View File

@ -4,7 +4,7 @@
left: 0;
width: 100%;
background: $white;
z-index: 300;
z-index: $zindex-dropdown-menu;
padding: 7px 0 6px; // to keep aligned with "collapse sidebar" button on the left sidebar
border-top: 1px solid $border-color;
padding-left: $contextual-sidebar-width;

View File

@ -14,7 +14,7 @@
top: 0;
margin-top: 3px;
padding: $gl-padding;
z-index: 300;
z-index: $zindex-dropdown-menu;
width: $award-emoji-width;
font-size: 14px;
background-color: $white;

View File

@ -471,7 +471,7 @@
background-color: $black-transparent;
height: 100%;
width: 100%;
z-index: 300;
z-index: $zindex-dropdown-menu;
}
}
}

View File

@ -216,7 +216,7 @@
position: absolute;
width: auto;
top: 100%;
z-index: 300;
z-index: $zindex-dropdown-menu;
min-width: 240px;
max-width: 500px;
margin-top: $dropdown-vertical-offset;

View File

@ -75,7 +75,7 @@
.right-sidebar-expanded {
padding-right: 0;
z-index: 300;
z-index: $zindex-dropdown-menu;
@include media-breakpoint-only(sm) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {

View File

@ -433,6 +433,7 @@ $browser-scrollbar-size: 10px;
*/
$header-height: 40px;
$header-zindex: 1000;
$zindex-dropdown-menu: 300;
$suggestion-header-height: 46px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;

View File

@ -124,7 +124,7 @@ $mr-widget-min-height: 69px;
padding: $gl-padding;
@include media-breakpoint-up(md) {
padding-left: $gl-padding-8 * 7;
margin-left: $gl-spacing-scale-7;
}
}
}
@ -221,7 +221,7 @@ $mr-widget-min-height: 69px;
.mr-widget-pipeline-graph {
.dropdown-menu {
z-index: 300;
z-index: $zindex-dropdown-menu;
}
}
@ -396,10 +396,6 @@ $mr-widget-min-height: 69px;
}
}
.mr-widget-help {
padding: 10px 16px 10px ($gl-padding-8 * 7);
}
.ci-coverage {
float: right;
}

View File

@ -110,7 +110,7 @@
// This utility is used to force the z-index to match that of dropdown menu's
.gl-z-dropdown-menu\! {
z-index: 300 !important;
z-index: $zindex-dropdown-menu !important;
}
.gl-flex-basis-quarter {

View File

@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true)
push_frontend_feature_flag(:codequality_mr_diff, @project)
push_frontend_feature_flag(:suggestions_custom_commit, @project)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)

View File

@ -1,11 +1,34 @@
#import "../fragments/linked_pipelines.fragment.graphql"
fragment LinkedPipelineData on Pipeline {
__typename
id
iid
path
status: detailedStatus {
__typename
group
label
icon
}
sourceJob {
__typename
name
}
project {
__typename
name
fullPath
}
}
query getPipelineDetails($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
__typename
pipeline(iid: $iid) {
__typename
id
iid
downstream {
__typename
nodes {
...LinkedPipelineData
}
@ -14,18 +37,25 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
...LinkedPipelineData
}
stages {
__typename
nodes {
__typename
name
status: detailedStatus {
__typename
action {
__typename
icon
path
title
}
}
groups {
__typename
nodes {
__typename
status: detailedStatus {
__typename
label
group
icon
@ -33,21 +63,27 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) {
name
size
jobs {
__typename
nodes {
__typename
name
scheduledAt
needs {
__typename
nodes {
__typename
name
}
}
status: detailedStatus {
__typename
icon
tooltip
hasDetails
detailsPath
group
action {
__typename
buttonTitle
icon
path

View File

@ -185,6 +185,10 @@ module MergeRequestsHelper
current_user.review_requested_open_merge_requests_count
end
def default_suggestion_commit_message
@project.suggestion_commit_message.presence || Gitlab::Suggestions::CommitMessage::DEFAULT_SUGGESTION_COMMIT_MESSAGE
end
end
MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper')

View File

@ -1020,8 +1020,6 @@ module Ci
end
def debug_mode?
return false unless Feature.enabled?(:restrict_access_to_build_debug_mode, default_enabled: true)
# TODO: Have `debug_mode?` check against data on sent back from runner
# to capture all the ways that variables can be set.
# See (https://gitlab.com/gitlab-org/gitlab/-/issues/290955)

View File

@ -83,6 +83,6 @@ module CacheableAttributes
end
def cache!
self.class.cache_backend.write(self.class.cache_key, self, expires_in: 1.minute)
self.class.cache_backend.write(self.class.cache_key, self, expires_in: Gitlab.config.gitlab['application_settings_cache_seconds'] || 60)
end
end

View File

@ -25,4 +25,4 @@
= _("Explain the problem. If appropriate, provide a link to the relevant issue or comment.")
.form-actions
= f.submit _("Send report"), class: "btn btn-success"
= f.submit _("Send report"), class: "gl-button btn btn-success"

View File

@ -33,5 +33,5 @@
= visibility_level_icon(group.visibility_level)
.controls.gl-flex-shrink-0.gl-ml-5
= link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn'
= link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'gl-button btn'
= link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'gl-button btn btn-danger'

View File

@ -4,8 +4,8 @@
- @projects.each_with_index do |project|
%li.project-row{ class: ('no-description' if project.description.blank?) }
.controls
= link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
%button.delete-project-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } }
= link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "gl-button btn"
%button.delete-project-button.gl-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } }
= s_('AdminProjects|Delete')
.stats

View File

@ -32,7 +32,7 @@
= image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160'
%h5.gl-mt-0= s_("Profiles|Upload new avatar")
.gl-mt-2.gl-mb-3
%button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
%button.gl-button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...")
%span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen")
= f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*'
.form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.")

View File

@ -25,7 +25,7 @@
.js-project-permissions-form
- if show_visibility_confirm_modal?(@project)
= render "visibility_modal"
= f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
= f.submit _('Save changes'), class: "gl-button gl-button btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
%section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
.settings-header
@ -39,7 +39,7 @@
= form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
= render 'projects/merge_request_settings', form: f
= f.submit _('Save changes'), class: "btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes"
= f.submit _('Save changes'), class: "gl-button btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes"
= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
@ -70,7 +70,7 @@
%h4= _('Housekeeping')
%p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
= link_to _('Run housekeeping'), housekeeping_project_path(@project),
method: :post, class: "btn btn-default"
method: :post, class: "gl-button btn btn-default"
= render 'export', project: @project
@ -92,7 +92,7 @@
%li= _('You will need to update your local repositories to point to the new location.')
- if @project.deployment_platform.present?
%li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
= f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
= f.submit _('Change path'), class: "gl-button btn btn-warning qa-change-path-button"
= render 'transfer', project: @project

View File

@ -90,7 +90,8 @@
dismiss_endpoint: user_callouts_path,
show_suggest_popover: show_suggest_popover?.to_s,
show_whitespace_default: @show_whitespace_default.to_s,
file_by_file_default: @file_by_file_default.to_s }
file_by_file_default: @file_by_file_default.to_s,
default_suggestion_commit_message: default_suggestion_commit_message }
.mr-loading-status
.loading.hide

View File

@ -21,8 +21,8 @@
.form-actions
- if @milestone.new_record?
= f.submit _('Create milestone'), class: 'btn-success btn', data: { qa_selector: 'create_milestone_button' }
= f.submit _('Create milestone'), class: 'gl-button btn-success btn', data: { qa_selector: 'create_milestone_button' }
= link_to _('Cancel'), project_milestones_path(@project), class: 'gl-button btn btn-cancel'
- else
= f.submit _('Save changes'), class: 'btn-success btn'
= f.submit _('Save changes'), class: 'gl-button btn-success btn'
= link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'gl-button btn btn-cancel'

View File

@ -6,6 +6,9 @@
- add_page_specific_style 'page_bundles/reports'
- add_page_specific_style 'page_bundles/ci_status'
- if Feature.enabled?(:graphql_pipeline_details, @project)
- add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid })
.js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } }
#js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } }
- if @pipeline.commit.present?

View File

@ -7,4 +7,4 @@
.gl-alert-body
= s_('AlertSettings|You can now set up alert endpoints for manually configured Prometheus instances in the Alerts section on the Operations settings page. Alert endpoint fields on this page have been deprecated.')
.gl-alert-actions
= link_to _('Visit settings page'), project_settings_operations_path(@project, anchor: 'js-alert-management-settings'), class: 'btn gl-alert-action btn-info gl-button'
= link_to _('Visit settings page'), project_settings_operations_path(@project, anchor: 'js-alert-management-settings'), class: 'gl-button btn gl-alert-action btn-info'

View File

@ -10,9 +10,9 @@
%p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= link_to _('Unarchive project'), unarchive_project_path(@project),
data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' },
method: :post, class: "btn btn-success"
method: :post, class: "gl-button btn btn-success"
- else
%p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= link_to _('Archive project'), archive_project_path(@project),
data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
method: :post, class: "btn btn-warning"
method: :post, class: "gl-button btn btn-warning"

View File

@ -40,4 +40,4 @@
%hr
= link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
= f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
= f.submit _('Save changes'), class: "gl-button btn btn-success gl-mt-6 qa-save-naming-topics-avatar-button"

View File

@ -10,7 +10,7 @@
.gl-alert-body
= _('Webhooks have moved. They can now be found under the Settings menu.')
.gl-alert-actions
= link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'btn gl-alert-action btn-info new-gl-button'
= link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'gl-button btn gl-alert-action btn-info'
%h4= _('Integrations')
- integrations_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('user/project/integrations/overview') }

View File

@ -1,7 +1,7 @@
- classes = local_assigns.fetch(:classes, '')
%span.js-filepicker
%button.btn.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…")
%button.gl-button.btn.js-filepicker-button{ type: 'button', class: classes }= _("Choose file…")
%span.file_name.js-filepicker-filename= _("No file chosen")
= f.file_field field, class: "js-filepicker-input hidden"
- if help_text.present?

View File

@ -46,7 +46,7 @@
%button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
%span= _('Unsubscribe')
.dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
%button.label-subscribe-button.btn.btn-default{ data: { toggle: 'dropdown' } }
%button.gl-button.label-subscribe-button.btn.btn-default{ data: { toggle: 'dropdown' } }
%span
= _('Subscribe')
= sprite_icon('chevron-down')
@ -59,7 +59,7 @@
%button.js-subscribe-button.js-group-level.label-subscribe-button.btn.btn-default{ class: ('hidden' unless status.unsubscribed?), data: { status: status, url: toggle_subscription_group_label_path(label.group, label) } }
%span= _('Subscribe at group level')
- else
%button.js-subscribe-button.label-subscribe-button.btn.btn-default{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
%button.gl-button.js-subscribe-button.label-subscribe-button.btn.btn-default{ data: { status: status, url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
%span= label_subscription_toggle_button_text(label, @project)
= render 'shared/delete_label_modal', label: label

View File

@ -12,9 +12,9 @@
%p= current_user_empty_message_description
- if secondary_button_link.present?
= link_to secondary_button_label, secondary_button_link, class: 'btn btn-success btn-inverted'
= link_to secondary_button_label, secondary_button_link, class: 'gl-button btn btn-success btn-inverted'
- if primary_button_link.present?
= link_to primary_button_label, primary_button_link, class: 'btn btn-success'
= link_to primary_button_label, primary_button_link, class: 'gl-button btn btn-success'
- else
%h5= visitor_empty_message

View File

@ -1,5 +1,5 @@
.dropdown.gl-ml-3#js-add-list
%button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
%button.gl-button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }

View File

@ -7,8 +7,8 @@
= form_tag [:bulk_update, @project, type], method: :post, class: "bulk-update" do
.block.issuable-sidebar-header
.filter-item.inline.update-issues-btn.float-left
= button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true
= button_tag _('Cancel'), class: "btn btn-default js-bulk-update-menu-hide float-right"
= button_tag _('Update all'), class: "gl-button btn update-selected-issues btn-info", disabled: true
= button_tag _('Cancel'), class: "gl-button btn btn-default js-bulk-update-menu-hide float-right"
- if params[:state] != 'merged'
.block
.title

View File

@ -19,7 +19,7 @@
%input.js-add-list{ type: "checkbox", name: "add_list", checked: add_list }
%span= _('Add list')
.clearfix
%button.btn.btn-primary.float-left.js-new-label-btn{ type: "button" }
%button.gl-button.btn.btn-success.float-left.js-new-label-btn{ type: "button" }
= _('Create')
%button.btn.btn-default.float-right.js-cancel-label-btn{ type: "button" }
%button.gl-button.btn.btn-default.float-right.js-cancel-label-btn{ type: "button" }
= _('Cancel')

View File

@ -0,0 +1,5 @@
---
title: Add API command to remove pending member invitation
merge_request: 51134
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Migrate old button classes to our Pajamas style GitLab button in multiple areas
merge_request: 51826
author: Yogi (@yo)
type: other

View File

@ -1,8 +0,0 @@
---
name: restrict_access_to_build_debug_mode
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48932
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/292661
milestone: '13.7'
type: development
group: group::continuous integration
default_enabled: true

View File

@ -0,0 +1,8 @@
---
name: suggestions_custom_commit
introduced_by_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/297404
milestone: '13.9'
type: development
group: group::code review
default_enabled: false

View File

@ -1,8 +0,0 @@
---
name: upload_middleware_jwt_params_handler
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33277
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/233895
milestone: '13.4'
type: development
group: group::package
default_enabled: true

View File

@ -165,6 +165,9 @@ production: &base
## Disable jQuery and CSS animations
# disable_animations: true
## Application settings cache expiry in seconds (default: 60)
# application_settings_cache_seconds: 60
## Reply by email
# Allow users to comment on issues and merge requests by replying to notification emails.
# For documentation on how to set this up, see http://doc.gitlab.com/ce/administration/reply_by_email.html

View File

@ -8,35 +8,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
Uploads represent all user data that may be sent to GitLab as a single file. As an example, avatars and notes' attachments are uploads. Uploads are integral to GitLab functionality, and therefore cannot be disabled.
## Upload parameters
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/214785) in GitLab 13.5.
> - It's [deployed behind a feature flag](../user/feature_flags.md), enabled by default.
> - It's enabled on GitLab.com.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to disable it. **(CORE ONLY)**
In 13.5 and later, upload parameters are passed [between Workhorse and GitLab Rails](../development/architecture.md#simplified-component-overview) differently than they
were before.
This change is deployed behind a feature flag that is **enabled by default**.
If you experience any issues with upload,
[GitLab administrators with access to the GitLab Rails console](feature_flags.md)
can opt to disable it.
To enable it:
```ruby
Feature.enable(:upload_middleware_jwt_params_handler)
```
To disable it:
```ruby
Feature.disable(:upload_middleware_jwt_params_handler)
```
## Using local storage
This is the default configuration. To change the location where the uploads are

View File

@ -26512,6 +26512,11 @@ type Vulnerability implements Noteable {
"""
description: String
"""
Details of the vulnerability
"""
details: [VulnerabilityDetail!]!
"""
Timestamp of when the vulnerability was first detected
"""
@ -26780,6 +26785,366 @@ type VulnerabilityConnection {
pageInfo: PageInfo!
}
"""
Represents a vulnerability detail field. The fields with data will depend on the vulnerability detail type
"""
union VulnerabilityDetail = VulnerabilityDetailBase | VulnerabilityDetailBoolean | VulnerabilityDetailCode | VulnerabilityDetailCommit | VulnerabilityDetailDiff | VulnerabilityDetailFileLocation | VulnerabilityDetailInt | VulnerabilityDetailList | VulnerabilityDetailMarkdown | VulnerabilityDetailModuleLocation | VulnerabilityDetailTable | VulnerabilityDetailText | VulnerabilityDetailUrl
"""
Represents the vulnerability details base
"""
type VulnerabilityDetailBase {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
}
"""
Represents the vulnerability details boolean value
"""
type VulnerabilityDetailBoolean {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
"""
Value of the field.
"""
value: Boolean!
}
"""
Represents the vulnerability details code field
"""
type VulnerabilityDetailCode {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Language of the code.
"""
lang: String
"""
Name of the field.
"""
name: String!
"""
Source code.
"""
value: String!
}
"""
Represents the vulnerability details commit field
"""
type VulnerabilityDetailCommit {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
"""
The commit SHA value.
"""
value: String!
}
"""
Represents the vulnerability details diff field
"""
type VulnerabilityDetailDiff {
"""
Value of the field after the change.
"""
after: String!
"""
Value of the field before the change.
"""
before: String!
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
}
"""
Represents the vulnerability details location within a file in the project
"""
type VulnerabilityDetailFileLocation {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
File name.
"""
fileName: String!
"""
End line number of the file location.
"""
lineEnd: Int!
"""
Start line number of the file location.
"""
lineStart: Int!
"""
Name of the field.
"""
name: String!
}
"""
Represents the vulnerability details integer value
"""
type VulnerabilityDetailInt {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
"""
Value of the field.
"""
value: Int!
}
"""
Represents the vulnerability details list value
"""
type VulnerabilityDetailList {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
List of details.
"""
items: [VulnerabilityDetail!]!
"""
Name of the field.
"""
name: String!
}
"""
Represents the vulnerability details Markdown field
"""
type VulnerabilityDetailMarkdown {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
"""
Value of the Markdown field.
"""
value: String!
}
"""
Represents the vulnerability details location within a file in the project
"""
type VulnerabilityDetailModuleLocation {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Module name.
"""
moduleName: String!
"""
Name of the field.
"""
name: String!
"""
Offset of the module location.
"""
offset: Int!
}
"""
Represents the vulnerability details table value
"""
type VulnerabilityDetailTable {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Table headers.
"""
headers: [VulnerabilityDetail!]!
"""
Name of the field.
"""
name: String!
"""
Table rows.
"""
rows: [VulnerabilityDetail!]!
}
"""
Represents the vulnerability details text field
"""
type VulnerabilityDetailText {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Name of the field.
"""
name: String!
"""
Value of the text field.
"""
value: String!
}
"""
Represents the vulnerability details URL field
"""
type VulnerabilityDetailUrl {
"""
Description of the field.
"""
description: String!
"""
Name of the field.
"""
fieldName: String
"""
Href of the URL.
"""
href: String!
"""
Name of the field.
"""
name: String!
"""
Text of the URL.
"""
text: String
}
"""
Autogenerated input type of VulnerabilityDismiss
"""

File diff suppressed because it is too large Load Diff

View File

@ -3937,6 +3937,7 @@ Represents a vulnerability.
| `confirmedAt` | Time | Timestamp of when the vulnerability state was changed to confirmed |
| `confirmedBy` | User | The user that confirmed the vulnerability. |
| `description` | String | Description of the vulnerability |
| `details` | VulnerabilityDetail! => Array | Details of the vulnerability |
| `detectedAt` | Time! | Timestamp of when the vulnerability was first detected |
| `discussions` | DiscussionConnection! | All discussions on this noteable |
| `dismissedAt` | Time | Timestamp of when the vulnerability state was changed to dismissed |
@ -3973,6 +3974,155 @@ Autogenerated return type of VulnerabilityConfirm.
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
| `vulnerability` | Vulnerability | The vulnerability after state change. |
### VulnerabilityDetailBase
Represents the vulnerability details base.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
### VulnerabilityDetailBoolean
Represents the vulnerability details boolean value.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
| `value` | Boolean! | Value of the field. |
### VulnerabilityDetailCode
Represents the vulnerability details code field.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `lang` | String | Language of the code. |
| `name` | String! | Name of the field. |
| `value` | String! | Source code. |
### VulnerabilityDetailCommit
Represents the vulnerability details commit field.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
| `value` | String! | The commit SHA value. |
### VulnerabilityDetailDiff
Represents the vulnerability details diff field.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `after` | String! | Value of the field after the change. |
| `before` | String! | Value of the field before the change. |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
### VulnerabilityDetailFileLocation
Represents the vulnerability details location within a file in the project.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `fileName` | String! | File name. |
| `lineEnd` | Int! | End line number of the file location. |
| `lineStart` | Int! | Start line number of the file location. |
| `name` | String! | Name of the field. |
### VulnerabilityDetailInt
Represents the vulnerability details integer value.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
| `value` | Int! | Value of the field. |
### VulnerabilityDetailList
Represents the vulnerability details list value.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `items` | VulnerabilityDetail! => Array | List of details. |
| `name` | String! | Name of the field. |
### VulnerabilityDetailMarkdown
Represents the vulnerability details Markdown field.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
| `value` | String! | Value of the Markdown field. |
### VulnerabilityDetailModuleLocation
Represents the vulnerability details location within a file in the project.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `moduleName` | String! | Module name. |
| `name` | String! | Name of the field. |
| `offset` | Int! | Offset of the module location. |
### VulnerabilityDetailTable
Represents the vulnerability details table value.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `headers` | VulnerabilityDetail! => Array | Table headers. |
| `name` | String! | Name of the field. |
| `rows` | VulnerabilityDetail! => Array | Table rows. |
### VulnerabilityDetailText
Represents the vulnerability details text field.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `name` | String! | Name of the field. |
| `value` | String! | Value of the text field. |
### VulnerabilityDetailUrl
Represents the vulnerability details URL field.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `description` | String! | Description of the field. |
| `fieldName` | String | Name of the field. |
| `href` | String! | Href of the URL. |
| `name` | String! | Name of the field. |
| `text` | String | Text of the URL. |
### VulnerabilityDismissPayload
Autogenerated return type of VulnerabilityDismiss.

View File

@ -106,3 +106,27 @@ Example response:
},
]
```
## Delete an invitation to a group or project
Deletes a pending invitation by email address.
```plaintext
DELETE /groups/:id/invitations/:email
DELETE /projects/:id/invitations/:email
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project or group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `email` | string | yes | The email address to which the invitation was previously sent |
```shell
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/55/invitations/email@example.org"
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/55/invitations/email@example.org"
```
- Returns `204` and no content on success.
- Returns `403` forbidden if unauthorized to delete the invitation.
- Returns `404` not found if authorized and no invitation is found for that email address.
- Returns `409` if the request was valid but the invitation could not be deleted.

View File

@ -877,13 +877,7 @@ before making them visible again.
### Restricted access to debug logging
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213159) in GitLab 13.7.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), enabled by default.
> - It's enabled on GitLab.com.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-restricted-access-to-debug-logging). **(CORE ONLY)**
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/292661) in GitLab 13.8.
With restricted access to debug logging, only users with
[developer or higher permissions](../../user/permissions.md#project-members-permissions)
@ -897,25 +891,6 @@ If you add `CI_DEBUG_TRACE` as a local variable to your runners, debug logs are
to all users with access to job logs. The permission levels are not checked by Runner,
so you should make use of the variable in GitLab only.
#### Enable or disable Restricted access to debug logging **(CORE ONLY)**
Restricted Access to Debug logging 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 enable it:
```ruby
Feature.enable(:restrict_access_to_build_debug_mode)
```
To disable it:
```ruby
Feature.disable(:restrict_access_to_build_debug_mode)
```
### Enable Debug logging
To enable debug logs (traces), set the `CI_DEBUG_TRACE` variable to `true`:

View File

@ -48,6 +48,24 @@ module API
present_member_invitations invitations
end
desc 'Removes an invitation from a group or project.'
params do
requires :email, type: String, desc: 'The email address of the invitation'
end
delete ":id/invitations/:email", requirements: { email: /[^\/]+/ } do
source = find_source(source_type, params[:id])
invite_email = params[:email]
authorize_admin_source!(source_type, source)
invite = retrieve_member_invitations(source, invite_email).first
not_found! unless invite
destroy_conditionally!(invite) do
::Members::DestroyService.new(current_user, params).execute(invite)
unprocessable_entity! unless invite.destroyed?
end
end
end
end
end

View File

@ -40,119 +40,6 @@ module Gitlab
@open_files = []
end
def with_open_files
@rewritten_fields.each do |field, tmp_path|
raise "invalid field: #{field.inspect}" unless valid_field_name?(field)
parsed_field = Rack::Utils.parse_nested_query(field)
raise "unexpected field: #{field.inspect}" unless parsed_field.count == 1
key, value = parsed_field.first
if value.nil? # we have a top level param, eg. field = 'foo' and not 'foo[bar]'
raise "invalid field: #{field.inspect}" if field != key
value = open_file(@request.params, key, tmp_path.presence)
@open_files << value
else
value = decorate_params_value(value, @request.params[key], tmp_path.presence)
end
update_param(key, value)
end
yield
ensure
@open_files.compact
.each(&:close)
end
# This function calls itself recursively
def decorate_params_value(path_hash, value_hash, path_override = nil)
unless path_hash.is_a?(Hash) && path_hash.count == 1
raise "invalid path: #{path_hash.inspect}"
end
path_key, path_value = path_hash.first
unless value_hash.is_a?(Hash) && value_hash[path_key]
raise "invalid value hash: #{value_hash.inspect}"
end
case path_value
when nil
value_hash[path_key] = open_file(value_hash.dig(path_key), '', path_override)
@open_files << value_hash[path_key]
value_hash
when Hash
decorate_params_value(path_value, value_hash[path_key], path_override)
value_hash
else
raise "unexpected path value: #{path_value.inspect}"
end
end
def open_file(params, key, path_override = nil)
::UploadedFile.from_params(params, key, allowed_paths, path_override)
end
# update_params ensures that both rails controllers and rack middleware can find
# workhorse accelerate files in the request
def update_param(key, value)
# we make sure we have key in POST otherwise update_params will add it in GET
@request.POST[key] ||= value
# this will force Rack::Request to properly update env keys
@request.update_param(key, value)
# ActionDispatch::Request is based on Rack::Request but it caches params
# inside other env keys, here we ensure everything is updated correctly
ActionDispatch::Request.new(@request.env).update_param(key, value)
end
private
def valid_field_name?(name)
# length validation
return false if name.size >= REWRITTEN_FIELD_NAME_MAX_LENGTH
# brackets validation
return false if name.include?('[]') || name.start_with?('[', ']')
return false unless ::Gitlab::Utils.valid_brackets?(name, allow_nested: false)
true
end
def package_allowed_paths
packages_config = ::Gitlab.config.packages
return [] unless allow_packages_storage_path?(packages_config)
[::Packages::PackageFileUploader.workhorse_upload_path]
end
def allow_packages_storage_path?(packages_config)
return false unless packages_config.enabled
return false unless packages_config['storage_path']
return false if packages_config.object_store.enabled && packages_config.object_store.direct_upload
true
end
def allowed_paths
[
Dir.tmpdir,
::FileUploader.root,
::Gitlab.config.uploads.storage_path,
::JobArtifactUploader.workhorse_upload_path,
::LfsObjectUploader.workhorse_upload_path,
File.join(Rails.root, 'public/uploads/tmp')
] + package_allowed_paths
end
end
# TODO this class is meant to replace Handler when the feature flag
# upload_middleware_jwt_params_handler is removed
# See https://gitlab.com/gitlab-org/gitlab/-/issues/233895#roll-out-steps
class HandlerForJWTParams < Handler
def with_open_files
@rewritten_fields.keys.each do |field|
raise "invalid field: #{field.inspect}" unless valid_field_name?(field)
@ -205,7 +92,21 @@ module Gitlab
end
def open_file(params)
::UploadedFile.from_params_without_field(params, allowed_paths)
::UploadedFile.from_params(params, allowed_paths)
end
# update_params ensures that both rails controllers and rack middleware can find
# workhorse accelerate files in the request
def update_param(key, value)
# we make sure we have key in POST otherwise update_params will add it in GET
@request.POST[key] ||= value
# this will force Rack::Request to properly update env keys
@request.update_param(key, value)
# ActionDispatch::Request is based on Rack::Request but it caches params
# inside other env keys, here we ensure everything is updated correctly
ActionDispatch::Request.new(@request.env).update_param(key, value)
end
private
@ -223,6 +124,43 @@ module Gitlab
upload_params
end
def valid_field_name?(name)
# length validation
return false if name.size >= REWRITTEN_FIELD_NAME_MAX_LENGTH
# brackets validation
return false if name.include?('[]') || name.start_with?('[', ']')
return false unless ::Gitlab::Utils.valid_brackets?(name, allow_nested: false)
true
end
def package_allowed_paths
packages_config = ::Gitlab.config.packages
return [] unless allow_packages_storage_path?(packages_config)
[::Packages::PackageFileUploader.workhorse_upload_path]
end
def allow_packages_storage_path?(packages_config)
return false unless packages_config.enabled
return false unless packages_config['storage_path']
return false if packages_config.object_store.enabled && packages_config.object_store.direct_upload
true
end
def allowed_paths
[
Dir.tmpdir,
::FileUploader.root,
::Gitlab.config.uploads.storage_path,
::JobArtifactUploader.workhorse_upload_path,
::LfsObjectUploader.workhorse_upload_path,
File.join(Rails.root, 'public/uploads/tmp')
] + package_allowed_paths
end
end
def initialize(app)
@ -235,22 +173,12 @@ module Gitlab
message = ::Gitlab::Workhorse.decode_jwt(encoded_message)[0]
handler_class.new(env, message).with_open_files do
::Gitlab::Middleware::Multipart::Handler.new(env, message).with_open_files do
@app.call(env)
end
rescue UploadedFile::InvalidPathError => e
[400, { 'Content-Type' => 'text/plain' }, e.message]
end
private
def handler_class
if Feature.enabled?(:upload_middleware_jwt_params_handler, default_enabled: true)
::Gitlab::Middleware::Multipart::HandlerForJWTParams
else
::Gitlab::Middleware::Multipart::Handler
end
end
end
end
end

View File

@ -42,10 +42,7 @@ class UploadedFile
@remote_id = remote_id
end
# TODO this function is meant to replace .from_params when the feature flag
# upload_middleware_jwt_params_handler is removed
# See https://gitlab.com/gitlab-org/gitlab/-/issues/233895#roll-out-steps
def self.from_params_without_field(params, upload_paths)
def self.from_params(params, upload_paths)
path = params['path']
remote_id = params['remote_id']
return if path.blank? && remote_id.blank?
@ -71,33 +68,6 @@ class UploadedFile
)
end
# Deprecated. Don't use it.
# .from_params_without_field will replace this one
# See .from_params_without_field and
# https://gitlab.com/gitlab-org/gitlab/-/issues/233895#roll-out-steps
def self.from_params(params, field, upload_paths, path_override = nil)
path = path_override || params["#{field}.path"]
remote_id = params["#{field}.remote_id"]
return if path.blank? && remote_id.blank?
if remote_id.present? # don't use file_path if remote_id is set
file_path = nil
elsif path.present?
file_path = File.realpath(path)
unless self.allowed_path?(file_path, Array(upload_paths).compact)
raise InvalidPathError, "insecure path used '#{file_path}'"
end
end
UploadedFile.new(file_path,
filename: params["#{field}.name"],
content_type: params["#{field}.type"] || 'application/octet-stream',
sha256: params["#{field}.sha256"],
remote_id: remote_id,
size: params["#{field}.size"])
end
def self.allowed_path?(file_path, paths)
paths.any? do |path|
File.exist?(path) && file_path.start_with?(File.realpath(path))

View File

@ -3543,12 +3543,6 @@ msgstr ""
msgid "Apply suggestion"
msgstr ""
msgid "Apply suggestion commit message"
msgstr ""
msgid "Apply suggestion on %{fileName}"
msgstr ""
msgid "Apply suggestions"
msgstr ""
@ -18469,9 +18463,6 @@ msgstr ""
msgid "ModalButton|Add projects"
msgstr ""
msgid "Modal|Cancel"
msgstr ""
msgid "Modal|Close"
msgstr ""

View File

@ -675,16 +675,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'with restrict_access_to_build_debug_mode feature disabled' do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
end
it 'returns response forbidden' do
expect(response).to have_gitlab_http_status(:ok)
end
end
end
end
end
@ -1139,18 +1129,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:ok)
end
context 'with restrict_access_to_build_debug_mode feature disabled' do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
end
it 'returns response ok' do
response = subject
expect(response).to have_gitlab_http_status(:ok)
end
end
end
context 'without proper permissions for debug logging on a project' do
@ -1164,18 +1142,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'with restrict_access_to_build_debug_mode feature disabled' do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
end
it 'returns response ok' do
response = subject
expect(response).to have_gitlab_http_status(:ok)
end
end
end
end
end

View File

@ -87,6 +87,7 @@ RSpec.describe 'User comments on a diff', :js do
expect(page).not_to have_content('Applied')
click_button('Apply suggestion')
click_button('Apply')
wait_for_requests
expect(page).to have_content('Applied')
@ -338,6 +339,7 @@ RSpec.describe 'User comments on a diff', :js do
expect(page).not_to have_content('Applied')
click_button('Apply suggestion')
click_button('Apply')
wait_for_requests
expect(page).to have_content('Applied')
@ -349,6 +351,7 @@ RSpec.describe 'User comments on a diff', :js do
expect(page).not_to have_content('Unresolve thread')
click_button('Apply suggestion')
click_button('Apply')
wait_for_requests
expect(page).to have_content('Unresolve thread')

View File

@ -179,34 +179,6 @@ RSpec.describe 'Project Jobs Permissions' do
expect(status_code).to eq(expected_status_code)
end
end
context 'when restrict_access_to_build_debug_mode feature not enabled' do
where(:public_builds, :user_project_role, :ci_debug_trace, :expected_status_code) do
true | 'developer' | true | 200
true | 'guest' | true | 200
true | 'developer' | false | 200
true | 'guest' | false | 200
false | 'developer' | true | 200
false | 'guest' | true | 403
false | 'developer' | false | 200
false | 'guest' | false | 403
end
with_them do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
ci_instance_variable.update!(value: ci_debug_trace)
project.update!(public_builds: public_builds)
project.add_role(user, user_project_role)
end
it 'renders trace to authorized users' do
visit trace_project_job_path(project, job)
expect(status_code).to eq(expected_status_code)
end
end
end
end
describe 'raw page' do
@ -237,35 +209,6 @@ RSpec.describe 'Project Jobs Permissions' do
expect(page).to have_content(expected_msg)
end
end
context 'when restrict_access_to_build_debug_mode feature not enabled' do
where(:public_builds, :user_project_role, :ci_debug_trace, :expected_status_code, :expected_msg) do
true | 'developer' | true | 200 | nil
true | 'guest' | true | 200 | nil
true | 'developer' | false | 200 | nil
true | 'guest' | false | 200 | nil
false | 'developer' | true | 200 | nil
false | 'guest' | true | 403 | 'The current user is not authorized to access the job log'
false | 'developer' | false | 200 | nil
false | 'guest' | false | 403 | 'The current user is not authorized to access the job log'
end
with_them do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
ci_instance_variable.update!(value: ci_debug_trace)
project.update!(public_builds: public_builds)
project.add_role(user, user_project_role)
end
it 'renders raw trace to authorized users' do
visit raw_project_job_path(project, job)
expect(status_code).to eq(expected_status_code)
expect(page).to have_content(expected_msg)
end
end
end
end
end
end

View File

@ -3,9 +3,9 @@ import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql';
import { mockPipelineResponse } from './mock_data';
const defaultProvide = {

View File

@ -1,10 +1,10 @@
import VueApollo from 'vue-apollo';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql';
import { DOWNSTREAM, GRAPHQL } from '~/pipelines/components/graph/constants';
import { LOAD_FAILURE } from '~/pipelines/constants';
import {

View File

@ -56,7 +56,7 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [],
},
},
@ -96,7 +96,7 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [],
},
},
@ -136,7 +136,7 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [],
},
},
@ -176,7 +176,7 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [],
},
},
@ -200,7 +200,7 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [],
},
},
@ -224,7 +224,7 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [],
},
},
@ -277,18 +277,18 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_c',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_b',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@ -331,26 +331,26 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_d 3/3',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_d 2/3',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_d 1/3',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_b',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@ -377,26 +377,26 @@ export const mockPipelineResponse = {
},
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_d 3/3',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_d 2/3',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_d 1/3',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_b',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@ -433,18 +433,18 @@ export const mockPipelineResponse = {
action: null,
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_c',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_b',
},
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@ -481,10 +481,10 @@ export const mockPipelineResponse = {
action: null,
},
needs: {
__typename: 'CiJobConnection',
__typename: 'CiBuildNeedConnection',
nodes: [
{
__typename: 'CiJob',
__typename: 'CiBuildNeed',
name: 'build_b',
},
],
@ -578,41 +578,54 @@ export const upstream = {
export const wrappedPipelineReturn = {
data: {
project: {
__typename: 'Project',
pipeline: {
__typename: 'Pipeline',
id: 'gid://gitlab/Ci::Pipeline/175',
iid: '38',
downstream: {
__typename: 'PipelineConnection',
nodes: [],
},
upstream: {
id: 'gid://gitlab/Ci::Pipeline/174',
iid: '37',
path: '/root/elemenohpee/-/pipelines/174',
__typename: 'Pipeline',
status: {
__typename: 'DetailedStatus',
group: 'success',
label: 'passed',
icon: 'status_success',
},
sourceJob: {
name: 'test_c',
__typename: 'CiJob',
},
project: {
id: 'gid://gitlab/Project/25',
name: 'elemenohpee',
fullPath: 'root/elemenohpee',
__typename: 'Project',
},
},
stages: {
__typename: 'CiStageConnection',
nodes: [
{
name: 'build',
__typename: 'CiStage',
status: {
action: null,
__typename: 'DetailedStatus',
},
groups: {
__typename: 'CiGroupConnection',
nodes: [
{
__typename: 'CiGroup',
status: {
__typename: 'DetailedStatus',
label: 'passed',
group: 'success',
icon: 'status_success',
@ -620,20 +633,25 @@ export const wrappedPipelineReturn = {
name: 'build_n',
size: 1,
jobs: {
__typename: 'CiJobConnection',
nodes: [
{
__typename: 'CiJob',
name: 'build_n',
scheduledAt: null,
needs: {
__typename: 'CiBuildNeedConnection',
nodes: [],
},
status: {
__typename: 'DetailedStatus',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
detailsPath: '/root/elemenohpee/-/jobs/1662',
group: 'success',
action: {
__typename: 'StatusAction',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/elemenohpee/-/jobs/1662/retry',

View File

@ -1,9 +1,9 @@
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'helpers/vue_mount_component_helper';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
import notify from '~/lib/utils/notify';
import SmartInterval from '~/smart_interval';
@ -24,51 +24,47 @@ const returnPromise = (data) =>
});
});
describe('mrWidgetOptions', () => {
let vm;
describe('MrWidgetOptions', () => {
let wrapper;
let mock;
let MrWidgetOptions;
const COLLABORATION_MESSAGE = 'Allows commits from members who can merge to the target branch';
beforeEach(() => {
// Prevent component mounting
delete mrWidgetOptions.el;
gl.mrWidgetData = { ...mockData };
gon.features = { asyncMrWidget: true };
mock = new MockAdapter(axios);
mock.onGet(mockData.merge_request_widget_path).reply(() => [200, { ...mockData }]);
mock.onGet(mockData.merge_request_cached_widget_path).reply(() => [200, { ...mockData }]);
MrWidgetOptions = Vue.extend(mrWidgetOptions);
});
afterEach(() => {
mock.restore();
vm.$destroy();
vm = null;
wrapper.destroy();
wrapper = null;
gl.mrWidgetData = {};
gon.features = {};
});
const createComponent = (mrData = mockData) => {
if (vm) {
vm.$destroy();
if (wrapper) {
wrapper.destroy();
}
vm = mountComponent(MrWidgetOptions, {
mrData: { ...mrData },
wrapper = mount(MrWidgetOptions, {
propsData: {
mrData: { ...mrData },
},
});
return axios.waitForAll();
};
const findSuggestPipeline = () => vm.$el.querySelector('[data-testid="mr-suggest-pipeline"]');
const findSuggestPipelineButton = () => findSuggestPipeline().querySelector('button');
const findSecurityMrWidget = () => vm.$el.querySelector('[data-testid="security-mr-widget"]');
const findSuggestPipeline = () => wrapper.find('[data-testid="mr-suggest-pipeline"]');
const findSuggestPipelineButton = () => findSuggestPipeline().find('button');
const findSecurityMrWidget = () => wrapper.find('[data-testid="security-mr-widget"]');
describe('default', () => {
beforeEach(() => {
@ -77,147 +73,147 @@ describe('mrWidgetOptions', () => {
describe('data', () => {
it('should instantiate Store and Service', () => {
expect(vm.mr).toBeDefined();
expect(vm.service).toBeDefined();
expect(wrapper.vm.mr).toBeDefined();
expect(wrapper.vm.service).toBeDefined();
});
});
describe('computed', () => {
describe('componentName', () => {
it('should return merged component', () => {
expect(vm.componentName).toEqual('mr-widget-merged');
expect(wrapper.vm.componentName).toEqual('mr-widget-merged');
});
it('should return conflicts component', () => {
vm.mr.state = 'conflicts';
wrapper.vm.mr.state = 'conflicts';
expect(vm.componentName).toEqual('mr-widget-conflicts');
expect(wrapper.vm.componentName).toEqual('mr-widget-conflicts');
});
});
describe('shouldRenderMergeHelp', () => {
it('should return false for the initial merged state', () => {
expect(vm.shouldRenderMergeHelp).toBeFalsy();
expect(wrapper.vm.shouldRenderMergeHelp).toBeFalsy();
});
it('should return true for a state which requires help widget', () => {
vm.mr.state = 'conflicts';
wrapper.vm.mr.state = 'conflicts';
expect(vm.shouldRenderMergeHelp).toBeTruthy();
expect(wrapper.vm.shouldRenderMergeHelp).toBeTruthy();
});
});
describe('shouldRenderPipelines', () => {
it('should return true when hasCI is true', () => {
vm.mr.hasCI = true;
wrapper.vm.mr.hasCI = true;
expect(vm.shouldRenderPipelines).toBeTruthy();
expect(wrapper.vm.shouldRenderPipelines).toBeTruthy();
});
it('should return false when hasCI is false', () => {
vm.mr.hasCI = false;
wrapper.vm.mr.hasCI = false;
expect(vm.shouldRenderPipelines).toBeFalsy();
expect(wrapper.vm.shouldRenderPipelines).toBeFalsy();
});
});
describe('shouldRenderRelatedLinks', () => {
it('should return false for the initial data', () => {
expect(vm.shouldRenderRelatedLinks).toBeFalsy();
expect(wrapper.vm.shouldRenderRelatedLinks).toBeFalsy();
});
it('should return true if there is relatedLinks in MR', () => {
Vue.set(vm.mr, 'relatedLinks', {});
Vue.set(wrapper.vm.mr, 'relatedLinks', {});
expect(vm.shouldRenderRelatedLinks).toBeTruthy();
expect(wrapper.vm.shouldRenderRelatedLinks).toBeTruthy();
});
});
describe('shouldRenderSourceBranchRemovalStatus', () => {
beforeEach(() => {
vm.mr.state = 'readyToMerge';
wrapper.vm.mr.state = 'readyToMerge';
});
it('should return true when cannot remove source branch and branch will be removed', () => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(true);
expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(true);
});
it('should return false when can remove source branch and branch will be removed', () => {
vm.mr.canRemoveSourceBranch = true;
vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.canRemoveSourceBranch = true;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
});
it('should return false when cannot remove source branch and branch will not be removed', () => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = false;
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = false;
expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
});
it('should return false when in merged state', () => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'merged';
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.state = 'merged';
expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
});
it('should return false when in nothing to merge state', () => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'nothingToMerge';
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.state = 'nothingToMerge';
expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
expect(wrapper.vm.shouldRenderSourceBranchRemovalStatus).toEqual(false);
});
});
describe('shouldRenderCollaborationStatus', () => {
describe('when collaboration is allowed', () => {
beforeEach(() => {
vm.mr.allowCollaboration = true;
wrapper.vm.mr.allowCollaboration = true;
});
describe('when merge request is opened', () => {
beforeEach((done) => {
vm.mr.isOpen = true;
vm.$nextTick(done);
wrapper.vm.mr.isOpen = true;
nextTick(done);
});
it('should render collaboration status', () => {
expect(vm.$el.textContent).toContain(COLLABORATION_MESSAGE);
expect(wrapper.text()).toContain(COLLABORATION_MESSAGE);
});
});
describe('when merge request is not opened', () => {
beforeEach((done) => {
vm.mr.isOpen = false;
vm.$nextTick(done);
wrapper.vm.mr.isOpen = false;
nextTick(done);
});
it('should not render collaboration status', () => {
expect(vm.$el.textContent).not.toContain(COLLABORATION_MESSAGE);
expect(wrapper.text()).not.toContain(COLLABORATION_MESSAGE);
});
});
});
describe('when collaboration is not allowed', () => {
beforeEach(() => {
vm.mr.allowCollaboration = false;
wrapper.vm.mr.allowCollaboration = false;
});
describe('when merge request is opened', () => {
beforeEach((done) => {
vm.mr.isOpen = true;
vm.$nextTick(done);
wrapper.vm.mr.isOpen = true;
nextTick(done);
});
it('should not render collaboration status', () => {
expect(vm.$el.textContent).not.toContain(COLLABORATION_MESSAGE);
expect(wrapper.text()).not.toContain(COLLABORATION_MESSAGE);
});
});
});
@ -226,55 +222,55 @@ describe('mrWidgetOptions', () => {
describe('showMergePipelineForkWarning', () => {
describe('when the source project and target project are the same', () => {
beforeEach((done) => {
Vue.set(vm.mr, 'mergePipelinesEnabled', true);
Vue.set(vm.mr, 'sourceProjectId', 1);
Vue.set(vm.mr, 'targetProjectId', 1);
vm.$nextTick(done);
Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true);
Vue.set(wrapper.vm.mr, 'sourceProjectId', 1);
Vue.set(wrapper.vm.mr, 'targetProjectId', 1);
nextTick(done);
});
it('should be false', () => {
expect(vm.showMergePipelineForkWarning).toEqual(false);
expect(wrapper.vm.showMergePipelineForkWarning).toEqual(false);
});
});
describe('when merge pipelines are not enabled', () => {
beforeEach((done) => {
Vue.set(vm.mr, 'mergePipelinesEnabled', false);
Vue.set(vm.mr, 'sourceProjectId', 1);
Vue.set(vm.mr, 'targetProjectId', 2);
vm.$nextTick(done);
Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', false);
Vue.set(wrapper.vm.mr, 'sourceProjectId', 1);
Vue.set(wrapper.vm.mr, 'targetProjectId', 2);
nextTick(done);
});
it('should be false', () => {
expect(vm.showMergePipelineForkWarning).toEqual(false);
expect(wrapper.vm.showMergePipelineForkWarning).toEqual(false);
});
});
describe('when merge pipelines are enabled _and_ the source project and target project are different', () => {
beforeEach((done) => {
Vue.set(vm.mr, 'mergePipelinesEnabled', true);
Vue.set(vm.mr, 'sourceProjectId', 1);
Vue.set(vm.mr, 'targetProjectId', 2);
vm.$nextTick(done);
Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true);
Vue.set(wrapper.vm.mr, 'sourceProjectId', 1);
Vue.set(wrapper.vm.mr, 'targetProjectId', 2);
nextTick(done);
});
it('should be true', () => {
expect(vm.showMergePipelineForkWarning).toEqual(true);
expect(wrapper.vm.showMergePipelineForkWarning).toEqual(true);
});
});
});
describe('formattedHumanAccess', () => {
it('when user is a tool admin but not a member of project', () => {
vm.mr.humanAccess = null;
wrapper.vm.mr.humanAccess = null;
expect(vm.formattedHumanAccess).toEqual('');
expect(wrapper.vm.formattedHumanAccess).toEqual('');
});
it('when user a member of the project', () => {
vm.mr.humanAccess = 'Owner';
wrapper.vm.mr.humanAccess = 'Owner';
expect(vm.formattedHumanAccess).toEqual('owner');
expect(wrapper.vm.formattedHumanAccess).toEqual('owner');
});
});
});
@ -285,9 +281,9 @@ describe('mrWidgetOptions', () => {
let isCbExecuted;
beforeEach(() => {
jest.spyOn(vm.service, 'checkStatus').mockReturnValue(returnPromise(mockData));
jest.spyOn(vm.mr, 'setData').mockImplementation(() => {});
jest.spyOn(vm, 'handleNotification').mockImplementation(() => {});
jest.spyOn(wrapper.vm.service, 'checkStatus').mockReturnValue(returnPromise(mockData));
jest.spyOn(wrapper.vm.mr, 'setData').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'handleNotification').mockImplementation(() => {});
isCbExecuted = false;
cb = () => {
@ -296,12 +292,12 @@ describe('mrWidgetOptions', () => {
});
it('should tell service to check status if document is visible', () => {
vm.checkStatus(cb);
wrapper.vm.checkStatus(cb);
return vm.$nextTick().then(() => {
expect(vm.service.checkStatus).toHaveBeenCalled();
expect(vm.mr.setData).toHaveBeenCalled();
expect(vm.handleNotification).toHaveBeenCalledWith(mockData);
return nextTick().then(() => {
expect(wrapper.vm.service.checkStatus).toHaveBeenCalled();
expect(wrapper.vm.mr.setData).toHaveBeenCalled();
expect(wrapper.vm.handleNotification).toHaveBeenCalledWith(mockData);
expect(isCbExecuted).toBeTruthy();
});
});
@ -309,11 +305,11 @@ describe('mrWidgetOptions', () => {
describe('initPolling', () => {
it('should call SmartInterval', () => {
vm.initPolling();
wrapper.vm.initPolling();
expect(SmartInterval).toHaveBeenCalledWith(
expect.objectContaining({
callback: vm.checkStatus,
callback: wrapper.vm.checkStatus,
}),
);
});
@ -321,11 +317,11 @@ describe('mrWidgetOptions', () => {
describe('initDeploymentsPolling', () => {
it('should call SmartInterval', () => {
vm.initDeploymentsPolling();
wrapper.vm.initDeploymentsPolling();
expect(SmartInterval).toHaveBeenCalledWith(
expect.objectContaining({
callback: vm.fetchPreMergeDeployments,
callback: wrapper.vm.fetchPreMergeDeployments,
}),
);
});
@ -334,15 +330,15 @@ describe('mrWidgetOptions', () => {
describe('fetchDeployments', () => {
it('should fetch deployments', () => {
jest
.spyOn(vm.service, 'fetchDeployments')
.spyOn(wrapper.vm.service, 'fetchDeployments')
.mockReturnValue(returnPromise([{ id: 1, status: SUCCESS }]));
vm.fetchPreMergeDeployments();
wrapper.vm.fetchPreMergeDeployments();
return vm.$nextTick().then(() => {
expect(vm.service.fetchDeployments).toHaveBeenCalled();
expect(vm.mr.deployments.length).toEqual(1);
expect(vm.mr.deployments[0].id).toBe(1);
return nextTick().then(() => {
expect(wrapper.vm.service.fetchDeployments).toHaveBeenCalled();
expect(wrapper.vm.mr.deployments.length).toEqual(1);
expect(wrapper.vm.mr.deployments[0].id).toBe(1);
});
});
});
@ -350,13 +346,13 @@ describe('mrWidgetOptions', () => {
describe('fetchActionsContent', () => {
it('should fetch content of Cherry Pick and Revert modals', () => {
jest
.spyOn(vm.service, 'fetchMergeActionsContent')
.spyOn(wrapper.vm.service, 'fetchMergeActionsContent')
.mockReturnValue(returnPromise('hello world'));
vm.fetchActionsContent();
wrapper.vm.fetchActionsContent();
return vm.$nextTick().then(() => {
expect(vm.service.fetchMergeActionsContent).toHaveBeenCalled();
return nextTick().then(() => {
expect(wrapper.vm.service.fetchMergeActionsContent).toHaveBeenCalled();
expect(document.body.textContent).toContain('hello world');
});
});
@ -371,40 +367,40 @@ describe('mrWidgetOptions', () => {
${'EnablePolling'} | ${'resumePolling'} | ${() => []}
${'DisablePolling'} | ${'stopPolling'} | ${() => []}
`('should bind to $event', ({ event, method, methodArgs }) => {
jest.spyOn(vm, method).mockImplementation();
jest.spyOn(wrapper.vm, method).mockImplementation();
const eventArg = {};
eventHub.$emit(event, eventArg);
expect(vm[method]).toHaveBeenCalledWith(...methodArgs(eventArg));
expect(wrapper.vm[method]).toHaveBeenCalledWith(...methodArgs(eventArg));
});
it('should bind to SetBranchRemoveFlag', () => {
expect(vm.mr.isRemovingSourceBranch).toBe(false);
expect(wrapper.vm.mr.isRemovingSourceBranch).toBe(false);
eventHub.$emit('SetBranchRemoveFlag', [true]);
expect(vm.mr.isRemovingSourceBranch).toBe(true);
expect(wrapper.vm.mr.isRemovingSourceBranch).toBe(true);
});
it('should bind to FailedToMerge', () => {
vm.mr.state = '';
vm.mr.mergeError = '';
wrapper.vm.mr.state = '';
wrapper.vm.mr.mergeError = '';
const mergeError = 'Something bad happened!';
eventHub.$emit('FailedToMerge', mergeError);
expect(vm.mr.state).toBe('failedToMerge');
expect(vm.mr.mergeError).toBe(mergeError);
expect(wrapper.vm.mr.state).toBe('failedToMerge');
expect(wrapper.vm.mr.mergeError).toBe(mergeError);
});
it('should bind to UpdateWidgetData', () => {
jest.spyOn(vm.mr, 'setData').mockImplementation();
jest.spyOn(wrapper.vm.mr, 'setData').mockImplementation();
const data = { ...mockData };
eventHub.$emit('UpdateWidgetData', data);
expect(vm.mr.setData).toHaveBeenCalledWith(data);
expect(wrapper.vm.mr.setData).toHaveBeenCalledWith(data);
});
});
@ -425,16 +421,17 @@ describe('mrWidgetOptions', () => {
});
it('should call setFavicon method', async () => {
vm.mr.ciStatusFaviconPath = overlayDataUrl;
wrapper.vm.mr.ciStatusFaviconPath = overlayDataUrl;
await vm.setFaviconHelper();
await wrapper.vm.setFaviconHelper();
expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl);
});
it('should not call setFavicon when there is no ciStatusFaviconPath', (done) => {
vm.mr.ciStatusFaviconPath = null;
vm.setFaviconHelper()
wrapper.vm.mr.ciStatusFaviconPath = null;
wrapper.vm
.setFaviconHelper()
.then(() => {
expect(faviconElement.getAttribute('href')).toEqual(null);
done();
@ -453,12 +450,12 @@ describe('mrWidgetOptions', () => {
beforeEach(() => {
jest.spyOn(notify, 'notifyMe').mockImplementation(() => {});
vm.mr.ciStatus = 'failed';
vm.mr.gitlabLogo = 'logo.png';
wrapper.vm.mr.ciStatus = 'failed';
wrapper.vm.mr.gitlabLogo = 'logo.png';
});
it('should call notifyMe', () => {
vm.handleNotification(data);
wrapper.vm.handleNotification(data);
expect(notify.notifyMe).toHaveBeenCalledWith(
'Pipeline running-label',
@ -468,15 +465,15 @@ describe('mrWidgetOptions', () => {
});
it('should not call notifyMe if the status has not changed', () => {
vm.mr.ciStatus = data.ci_status;
wrapper.vm.mr.ciStatus = data.ci_status;
vm.handleNotification(data);
wrapper.vm.handleNotification(data);
expect(notify.notifyMe).not.toHaveBeenCalled();
});
it('should not notify if no pipeline provided', () => {
vm.handleNotification({
wrapper.vm.handleNotification({
...data,
pipeline: undefined,
});
@ -487,47 +484,49 @@ describe('mrWidgetOptions', () => {
describe('resumePolling', () => {
it('should call stopTimer on pollingInterval', () => {
jest.spyOn(vm.pollingInterval, 'resume').mockImplementation(() => {});
jest.spyOn(wrapper.vm.pollingInterval, 'resume').mockImplementation(() => {});
vm.resumePolling();
wrapper.vm.resumePolling();
expect(vm.pollingInterval.resume).toHaveBeenCalled();
expect(wrapper.vm.pollingInterval.resume).toHaveBeenCalled();
});
});
describe('stopPolling', () => {
it('should call stopTimer on pollingInterval', () => {
jest.spyOn(vm.pollingInterval, 'stopTimer').mockImplementation(() => {});
jest.spyOn(wrapper.vm.pollingInterval, 'stopTimer').mockImplementation(() => {});
vm.stopPolling();
wrapper.vm.stopPolling();
expect(vm.pollingInterval.stopTimer).toHaveBeenCalled();
expect(wrapper.vm.pollingInterval.stopTimer).toHaveBeenCalled();
});
});
});
describe('rendering relatedLinks', () => {
beforeEach((done) => {
vm.mr.relatedLinks = {
assignToMe: null,
closing: `
<a class="close-related-link" href="#">
Close
</a>
`,
mentioned: '',
};
Vue.nextTick(done);
beforeEach(() => {
createComponent({
...mockData,
issues_links: {
closing: `
<a class="close-related-link" href="#">
Close
</a>
`,
},
});
return nextTick();
});
it('renders if there are relatedLinks', () => {
expect(vm.$el.querySelector('.close-related-link')).toBeDefined();
expect(wrapper.find('.close-related-link').exists()).toBe(true);
});
it('does not render if state is nothingToMerge', (done) => {
vm.mr.state = stateKey.nothingToMerge;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.close-related-link')).toBeNull();
wrapper.vm.mr.state = stateKey.nothingToMerge;
nextTick(() => {
expect(wrapper.find('.close-related-link').exists()).toBe(false);
done();
});
});
@ -535,15 +534,15 @@ describe('mrWidgetOptions', () => {
describe('rendering source branch removal status', () => {
it('renders when user cannot remove branch and branch should be removed', (done) => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'readyToMerge';
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.state = 'readyToMerge';
vm.$nextTick(() => {
const tooltip = vm.$el.querySelector('[data-testid="question-o-icon"]');
nextTick(() => {
const tooltip = wrapper.find('[data-testid="question-o-icon"]');
expect(vm.$el.textContent).toContain('Deletes source branch');
expect(tooltip.getAttribute('title')).toBe(
expect(wrapper.text()).toContain('Deletes source branch');
expect(tooltip.attributes('title')).toBe(
'A user with write access to the source branch selected this option',
);
@ -552,13 +551,13 @@ describe('mrWidgetOptions', () => {
});
it('does not render in merged state', (done) => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'merged';
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.state = 'merged';
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain('The source branch has been deleted');
expect(vm.$el.textContent).not.toContain('Deletes source branch');
nextTick(() => {
expect(wrapper.text()).toContain('The source branch has been deleted');
expect(wrapper.text()).not.toContain('Deletes source branch');
done();
});
@ -596,7 +595,7 @@ describe('mrWidgetOptions', () => {
};
beforeEach((done) => {
vm.mr.deployments.push(
wrapper.vm.mr.deployments.push(
{
...deploymentMockData,
},
@ -606,33 +605,32 @@ describe('mrWidgetOptions', () => {
},
);
vm.$nextTick(done);
nextTick(done);
});
it('renders multiple deployments', () => {
expect(vm.$el.querySelectorAll('.deploy-heading').length).toBe(2);
expect(wrapper.findAll('.deploy-heading').length).toBe(2);
});
it('renders dropdpown with multiple file changes', () => {
expect(
vm.$el
.querySelector('.js-mr-wigdet-deployment-dropdown')
.querySelectorAll('.js-filtered-dropdown-result').length,
wrapper.find('.js-mr-wigdet-deployment-dropdown').findAll('.js-filtered-dropdown-result')
.length,
).toEqual(changes.length);
});
});
describe('code quality widget', () => {
it('renders the component', () => {
expect(vm.$el.querySelector('.js-codequality-widget')).toExist();
expect(wrapper.find('.js-codequality-widget').exists()).toBe(true);
});
});
describe('pipeline for target branch after merge', () => {
describe('with information for target branch pipeline', () => {
beforeEach((done) => {
vm.mr.state = 'merged';
vm.mr.mergePipeline = {
wrapper.vm.mr.state = 'merged';
wrapper.vm.mr.mergePipeline = {
id: 127,
user: {
id: 1,
@ -738,16 +736,16 @@ describe('mrWidgetOptions', () => {
},
cancel_path: '/root/ci-web-terminal/pipelines/127/cancel',
};
vm.$nextTick(done);
nextTick(done);
});
it('renders pipeline block', () => {
expect(vm.$el.querySelector('.js-post-merge-pipeline')).not.toBeNull();
expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(true);
});
describe('with post merge deployments', () => {
beforeEach((done) => {
vm.mr.postMergeDeployments = [
wrapper.vm.mr.postMergeDeployments = [
{
id: 15,
name: 'review/diplo',
@ -779,46 +777,46 @@ describe('mrWidgetOptions', () => {
},
];
vm.$nextTick(done);
nextTick(done);
});
it('renders post deployment information', () => {
expect(vm.$el.querySelector('.js-post-deployment')).not.toBeNull();
expect(wrapper.find('.js-post-deployment').exists()).toBe(true);
});
});
});
describe('without information for target branch pipeline', () => {
beforeEach((done) => {
vm.mr.state = 'merged';
wrapper.vm.mr.state = 'merged';
vm.$nextTick(done);
nextTick(done);
});
it('does not render pipeline block', () => {
expect(vm.$el.querySelector('.js-post-merge-pipeline')).toBeNull();
expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(false);
});
});
describe('when state is not merged', () => {
beforeEach((done) => {
vm.mr.state = 'archived';
wrapper.vm.mr.state = 'archived';
vm.$nextTick(done);
nextTick(done);
});
it('does not render pipeline block', () => {
expect(vm.$el.querySelector('.js-post-merge-pipeline')).toBeNull();
expect(wrapper.find('.js-post-merge-pipeline').exists()).toBe(false);
});
it('does not render post deployment information', () => {
expect(vm.$el.querySelector('.js-post-deployment')).toBeNull();
expect(wrapper.find('.js-post-deployment').exists()).toBe(false);
});
});
});
it('should not suggest pipelines when feature flag is not present', () => {
expect(findSuggestPipeline()).toBeNull();
expect(findSuggestPipeline().exists()).toBe(false);
});
});
@ -847,11 +845,11 @@ describe('mrWidgetOptions', () => {
if (shouldRender) {
it('renders', () => {
expect(findSecurityMrWidget()).toEqual(expect.any(HTMLElement));
expect(findSecurityMrWidget().exists()).toBe(true);
});
} else {
it('does not render', () => {
expect(findSecurityMrWidget()).toBeNull();
expect(findSecurityMrWidget().exists()).toBe(false);
});
}
});
@ -860,21 +858,17 @@ describe('mrWidgetOptions', () => {
describe('suggestPipeline', () => {
beforeEach(() => {
mock.onAny().reply(200);
// This is needed because some grandchildren Bootstrap components throw warnings
// https://gitlab.com/gitlab-org/gitlab/issues/208458
jest.spyOn(console, 'warn').mockImplementation();
});
describe('given feature flag is enabled', () => {
beforeEach(() => {
createComponent();
vm.mr.hasCI = false;
wrapper.vm.mr.hasCI = false;
});
it('should suggest pipelines when none exist', () => {
expect(findSuggestPipeline()).toEqual(expect.any(Element));
expect(findSuggestPipeline().exists()).toBe(true);
});
it.each([
@ -882,19 +876,17 @@ describe('mrWidgetOptions', () => {
{ mergeRequestAddCiConfigPath: null },
{ hasCI: true },
])('with %s, should not suggest pipeline', async (obj) => {
Object.assign(vm.mr, obj);
Object.assign(wrapper.vm.mr, obj);
await vm.$nextTick();
await nextTick();
expect(findSuggestPipeline()).toBeNull();
expect(findSuggestPipeline().exists()).toBe(false);
});
it('should allow dismiss of the suggest pipeline message', async () => {
findSuggestPipelineButton().click();
await findSuggestPipelineButton().trigger('click');
await vm.$nextTick();
expect(findSuggestPipeline()).toBeNull();
expect(findSuggestPipeline().exists()).toBe(false);
});
});
});

View File

@ -1,258 +0,0 @@
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
const modalComponent = Vue.extend(DeprecatedModal2);
describe('DeprecatedModal2', () => {
let vm;
afterEach(() => {
vm.$destroy();
});
describe('props', () => {
describe('with id', () => {
const props = {
id: 'my-modal',
};
beforeEach(() => {
vm = mountComponent(modalComponent, props);
});
it('assigns the id to the modal', () => {
expect(vm.$el.id).toBe(props.id);
});
});
describe('without id', () => {
beforeEach(() => {
vm = mountComponent(modalComponent, {});
});
it('does not add an id attribute to the modal', () => {
expect(vm.$el.hasAttribute('id')).toBe(false);
});
});
describe('with headerTitleText', () => {
const props = {
headerTitleText: 'my title text',
};
beforeEach(() => {
vm = mountComponent(modalComponent, props);
});
it('sets the modal title', () => {
const modalTitle = vm.$el.querySelector('.modal-title');
expect(modalTitle.innerHTML.trim()).toBe(props.headerTitleText);
});
});
describe('with footerPrimaryButtonVariant', () => {
const props = {
footerPrimaryButtonVariant: 'danger',
};
beforeEach(() => {
vm = mountComponent(modalComponent, props);
});
it('sets the primary button class', () => {
const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type');
expect(primaryButton).toHaveClass(`btn-${props.footerPrimaryButtonVariant}`);
});
});
describe('with footerPrimaryButtonText', () => {
const props = {
footerPrimaryButtonText: 'my button text',
};
beforeEach(() => {
vm = mountComponent(modalComponent, props);
});
it('sets the primary button text', () => {
const primaryButton = vm.$el.querySelector('.js-modal-primary-action .gl-button-text');
expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText);
});
});
});
it('works with data-toggle="modal"', () => {
setFixtures(`
<button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
<div id="modal-container"></div>
`);
const modalContainer = document.getElementById('modal-container');
const modalButton = document.getElementById('modal-button');
vm = mountComponent(
modalComponent,
{
id: 'my-modal',
},
modalContainer,
);
const modalElement = document.getElementById('my-modal');
modalButton.click();
expect(modalElement).not.toHaveClass('show');
// let the modal fade in
jest.runOnlyPendingTimers();
expect(modalElement).toHaveClass('show');
});
describe('methods', () => {
const dummyEvent = 'not really an event';
beforeEach(() => {
vm = mountComponent(modalComponent, {});
jest.spyOn(vm, '$emit').mockImplementation(() => {});
});
describe('emitCancel', () => {
it('emits a cancel event', () => {
vm.emitCancel(dummyEvent);
expect(vm.$emit).toHaveBeenCalledWith('cancel', dummyEvent);
});
});
describe('emitSubmit', () => {
it('emits a submit event', () => {
vm.emitSubmit(dummyEvent);
expect(vm.$emit).toHaveBeenCalledWith('submit', dummyEvent);
});
});
describe('opened', () => {
it('emits a open event', () => {
vm.opened();
expect(vm.$emit).toHaveBeenCalledWith('open');
});
});
describe('closed', () => {
it('emits a closed event', () => {
vm.closed();
expect(vm.$emit).toHaveBeenCalledWith('closed');
});
});
});
describe('slots', () => {
const slotContent = 'this should go into the slot';
const modalWithSlot = (slot) => {
return Vue.extend({
components: {
DeprecatedModal2,
},
render: (h) =>
h('deprecated-modal-2', [slot ? h('template', { slot }, slotContent) : slotContent]),
});
};
describe('default slot', () => {
beforeEach(() => {
vm = mountComponent(modalWithSlot());
});
it('sets the modal body', () => {
const modalBody = vm.$el.querySelector('.modal-body');
expect(modalBody.innerHTML).toBe(slotContent);
});
});
describe('header slot', () => {
beforeEach(() => {
vm = mountComponent(modalWithSlot('header'));
});
it('sets the modal header', () => {
const modalHeader = vm.$el.querySelector('.modal-header');
expect(modalHeader.innerHTML).toBe(slotContent);
});
});
describe('title slot', () => {
beforeEach(() => {
vm = mountComponent(modalWithSlot('title'));
});
it('sets the modal title', () => {
const modalTitle = vm.$el.querySelector('.modal-title');
expect(modalTitle.innerHTML).toBe(slotContent);
});
});
describe('footer slot', () => {
beforeEach(() => {
vm = mountComponent(modalWithSlot('footer'));
});
it('sets the modal footer', () => {
const modalFooter = vm.$el.querySelector('.modal-footer');
expect(modalFooter.innerHTML).toBe(slotContent);
});
});
});
describe('handling sizes', () => {
it('should render modal-sm', () => {
vm = mountComponent(modalComponent, {
modalSize: 'sm',
});
expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-sm')).toEqual(true);
});
it('should render modal-lg', () => {
vm = mountComponent(modalComponent, {
modalSize: 'lg',
});
expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-lg')).toEqual(true);
});
it('should render modal-xl', () => {
vm = mountComponent(modalComponent, {
modalSize: 'xl',
});
expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-xl')).toEqual(true);
});
it('should not add modal size classes when md size is passed', () => {
vm = mountComponent(modalComponent, {
modalSize: 'md',
});
expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-md')).toEqual(false);
});
it('should not add modal size classes by default', () => {
vm = mountComponent(modalComponent, {});
expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-sm')).toEqual(false);
expect(vm.$el.querySelector('.modal-dialog').classList.contains('modal-lg')).toEqual(false);
});
});
});

View File

@ -7,6 +7,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
<suggestion-diff-header-stub
batchsuggestionscount="1"
class="qa-suggestion-diff-header js-suggestion-diff-header"
defaultcommitmessage="Apply suggestion"
helppagepath="path_to_docs"
isapplyingbatch="true"
isbatched="true"

View File

@ -3,7 +3,7 @@ import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui';
import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue';
describe('Apply Suggestion component', () => {
const propsData = { fileName: 'test.js', disabled: false };
const propsData = { defaultCommitMessage: 'Apply suggestion', disabled: false };
let wrapper;
const createWrapper = (props) => {
@ -27,7 +27,6 @@ describe('Apply Suggestion component', () => {
expect(dropdown.exists()).toBe(true);
expect(dropdown.props('text')).toBe('Apply suggestion');
expect(dropdown.props('headerText')).toBe('Apply suggestion commit message');
expect(dropdown.props('disabled')).toBe(false);
});
@ -35,7 +34,7 @@ describe('Apply Suggestion component', () => {
const textArea = findTextArea();
expect(textArea.exists()).toBe(true);
expect(textArea.attributes('placeholder')).toBe('Apply suggestion on test.js');
expect(textArea.attributes('placeholder')).toBe('Apply suggestion');
});
it('renders an apply button', () => {
@ -55,11 +54,11 @@ describe('Apply Suggestion component', () => {
});
describe('apply suggestion', () => {
it('emits an apply event with a default message if no message was added', () => {
it('emits an apply event with no message if no message was added', () => {
findTextArea().vm.$emit('input', null);
findApplyButton().vm.$emit('click');
expect(wrapper.emitted('apply')).toEqual([['Apply suggestion on test.js']]);
expect(wrapper.emitted('apply')).toEqual([[null]]);
});
it('emits an apply event with a user-defined message', () => {

View File

@ -9,6 +9,7 @@ const DEFAULT_PROPS = {
isBatched: false,
isApplyingBatch: false,
helpPagePath: 'path_to_docs',
defaultCommitMessage: 'Apply suggestion',
};
describe('Suggestion Diff component', () => {
@ -91,7 +92,7 @@ describe('Suggestion Diff component', () => {
});
it('emits apply', () => {
expect(wrapper.emitted().apply).toEqual([[expect.any(Function)]]);
expect(wrapper.emitted().apply).toEqual([[expect.any(Function), undefined]]);
});
it('does not render apply suggestion and add to batch buttons', () => {

View File

@ -42,6 +42,7 @@ const MOCK_DATA = {
is_applying_batch: true,
},
helpPagePath: 'path_to_docs',
defaultCommitMessage: 'Apply suggestion',
batchSuggestionsInfo: [{ suggestionId }],
};

View File

@ -44,6 +44,7 @@ const MOCK_DATA = {
`,
isApplied: false,
helpPagePath: 'path_to_docs',
defaultCommitMessage: 'Apply suggestion',
};
describe('Suggestion component', () => {

View File

@ -1,53 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Middleware::Multipart::HandlerForJWTParams do
using RSpec::Parameterized::TableSyntax
let_it_be(:env) { Rack::MockRequest.env_for('/', method: 'post', params: {}) }
let_it_be(:message) { { 'rewritten_fields' => {} } }
describe '#allowed_paths' do
let_it_be(:expected_allowed_paths) do
[
Dir.tmpdir,
::FileUploader.root,
::Gitlab.config.uploads.storage_path,
::JobArtifactUploader.workhorse_upload_path,
::LfsObjectUploader.workhorse_upload_path,
File.join(Rails.root, 'public/uploads/tmp')
]
end
let_it_be(:expected_with_packages_path) { expected_allowed_paths + [::Packages::PackageFileUploader.workhorse_upload_path] }
subject { described_class.new(env, message).send(:allowed_paths) }
where(:package_features_enabled, :object_storage_enabled, :direct_upload_enabled, :expected_paths) do
false | false | true | :expected_allowed_paths
false | false | false | :expected_allowed_paths
false | true | true | :expected_allowed_paths
false | true | false | :expected_allowed_paths
true | false | true | :expected_with_packages_path
true | false | false | :expected_with_packages_path
true | true | true | :expected_allowed_paths
true | true | false | :expected_with_packages_path
end
with_them do
before do
stub_config(packages: {
enabled: package_features_enabled,
object_store: {
enabled: object_storage_enabled,
direct_upload: direct_upload_enabled
},
storage_path: '/any/dir'
})
end
it { is_expected.to eq(send(expected_paths)) }
end
end
end

View File

@ -21,10 +21,6 @@ RSpec.describe Gitlab::Middleware::Multipart do
middleware.call(env)
end
before do
stub_feature_flags(upload_middleware_jwt_params_handler: true)
end
context 'remote file mode' do
let(:mode) { :remote }
@ -34,7 +30,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
include_context 'with one temporary file for multipart'
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(key: 'file', filename: filename, remote_id: remote_id).merge('file.path' => '/should/not/be/read') }
let(:params) { upload_parameters_for(key: 'file', mode: mode, filename: filename, remote_id: remote_id).merge('file.path' => '/should/not/be/read') }
it 'builds an UploadedFile' do
expect_uploaded_files(original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file))
@ -55,14 +51,14 @@ RSpec.describe Gitlab::Middleware::Multipart do
let(:allowed_paths) { [Dir.tmpdir] }
before do
expect_next_instance_of(::Gitlab::Middleware::Multipart::HandlerForJWTParams) do |handler|
expect_next_instance_of(::Gitlab::Middleware::Multipart::Handler) do |handler|
expect(handler).to receive(:allowed_paths).and_return(allowed_paths)
end
end
context 'in allowed paths' do
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', mode: mode, filename: filename) }
it 'builds an UploadedFile' do
expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, size: uploaded_file.size, params_path: %w(file))
@ -75,7 +71,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
let(:allowed_paths) { [] }
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file') }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', mode: mode) }
it 'returns an error' do
result = subject
@ -89,7 +85,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
context 'with dummy params in remote mode' do
let(:rewritten_fields) { { 'file' => 'should/not/be/read' } }
let(:params) { upload_parameters_for(key: 'file') }
let(:params) { upload_parameters_for(key: 'file', mode: mode) }
let(:mode) { :remote }
context 'with an invalid secret' do
@ -128,7 +124,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
RSpec.shared_examples 'rejecting the invalid key' do |key_in_header:, key_in_upload_params:, error_message:|
let(:rewritten_fields) { rewritten_fields_hash(key_in_header => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: key_in_upload_params, filename: filename, remote_id: remote_id) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: key_in_upload_params, mode: mode, filename: filename, remote_id: remote_id) }
it 'raises an error' do
expect { subject }.to raise_error(RuntimeError, error_message)
@ -171,7 +167,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:crafted_payload) { Base64.urlsafe_encode64({ 'path' => 'test' }.to_json) }
let(:params) do
upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id).tap do |params|
upload_parameters_for(filepath: uploaded_filepath, key: 'file', mode: mode, filename: filename, remote_id: remote_id).tap do |params|
header, _, sig = params['file.gitlab-workhorse-upload'].split('.')
params['file.gitlab-workhorse-upload'] = [header, crafted_payload, sig].join('.')
end
@ -187,7 +183,7 @@ RSpec.describe Gitlab::Middleware::Multipart do
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) do
upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id).tap do |params|
upload_parameters_for(filepath: uploaded_filepath, key: 'file', mode: mode, filename: filename, remote_id: remote_id).tap do |params|
header, payload, sig = params['file.gitlab-workhorse-upload'].split('.')
params['file.gitlab-workhorse-upload'] = [header, payload, "#{sig}modified"].join('.')
end

View File

@ -1,196 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Middleware::Multipart do
include MultipartHelpers
describe '#call' do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
let(:secret) { Gitlab::Workhorse.secret }
let(:issuer) { 'gitlab-workhorse' }
subject do
env = post_env(
rewritten_fields: rewritten_fields,
params: params,
secret: secret,
issuer: issuer
)
middleware.call(env)
end
before do
stub_feature_flags(upload_middleware_jwt_params_handler: false)
end
context 'remote file mode' do
let(:mode) { :remote }
it_behaves_like 'handling all upload parameters conditions'
context 'and a path set' do
include_context 'with one temporary file for multipart'
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(key: 'file', filename: filename, remote_id: remote_id).merge('file.path' => '/should/not/be/read') }
it 'builds an UploadedFile' do
expect_uploaded_files(original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file))
subject
end
end
end
context 'local file mode' do
let(:mode) { :local }
it_behaves_like 'handling all upload parameters conditions'
context 'when file is' do
include_context 'with one temporary file for multipart'
let(:allowed_paths) { [Dir.tmpdir] }
before do
expect_next_instance_of(::Gitlab::Middleware::Multipart::Handler) do |handler|
expect(handler).to receive(:allowed_paths).and_return(allowed_paths)
end
end
context 'in allowed paths' do
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename) }
it 'builds an UploadedFile' do
expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, size: uploaded_file.size, params_path: %w(file))
subject
end
end
context 'not in allowed paths' do
let(:allowed_paths) { [] }
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file') }
it 'returns an error' do
result = subject
expect(result[0]).to eq(400)
expect(result[2]).to include('insecure path used')
end
end
end
end
context 'with dummy params in remote mode' do
let(:rewritten_fields) { { 'file' => 'should/not/be/read' } }
let(:params) { upload_parameters_for(key: 'file') }
let(:mode) { :remote }
context 'with an invalid secret' do
let(:secret) { 'INVALID_SECRET' }
it { expect { subject }.to raise_error(JWT::VerificationError) }
end
context 'with an invalid issuer' do
let(:issuer) { 'INVALID_ISSUER' }
it { expect { subject }.to raise_error(JWT::InvalidIssuerError) }
end
context 'with invalid rewritten field key' do
invalid_keys = [
'[file]',
';file',
'file]',
';file]',
'file]]',
'file;;'
]
invalid_keys.each do |invalid_key|
context invalid_key do
let(:rewritten_fields) { { invalid_key => 'should/not/be/read' } }
it { expect { subject }.to raise_error(RuntimeError, "invalid field: \"#{invalid_key}\"") }
end
end
end
context 'with invalid key in parameters' do
include_context 'with one temporary file for multipart'
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'wrong_key', filename: filename, remote_id: remote_id) }
it 'builds no UploadedFile' do
expect(app).to receive(:call) do |env|
received_params = get_params(env)
expect(received_params['file']).to be_nil
expect(received_params['wrong_key']).to be_nil
end
subject
end
end
context 'with invalid key in header' do
include_context 'with one temporary file for multipart'
RSpec.shared_examples 'rejecting the invalid key' do |key_in_header:, key_in_upload_params:, error_message:|
let(:rewritten_fields) { rewritten_fields_hash(key_in_header => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: key_in_upload_params, filename: filename, remote_id: remote_id) }
it 'raises an error' do
expect { subject }.to raise_error(RuntimeError, error_message)
end
end
it_behaves_like 'rejecting the invalid key',
key_in_header: 'user[avatar',
key_in_upload_params: 'user[avatar]',
error_message: 'invalid field: "user[avatar"'
it_behaves_like 'rejecting the invalid key',
key_in_header: '[user]avatar',
key_in_upload_params: 'user[avatar]',
error_message: 'invalid field: "[user]avatar"'
it_behaves_like 'rejecting the invalid key',
key_in_header: 'user[]avatar',
key_in_upload_params: 'user[avatar]',
error_message: 'invalid field: "user[]avatar"'
it_behaves_like 'rejecting the invalid key',
key_in_header: 'user[avatar[image[url]]]',
key_in_upload_params: 'user[avatar]',
error_message: 'invalid field: "user[avatar[image[url]]]"'
it_behaves_like 'rejecting the invalid key',
key_in_header: '[]',
key_in_upload_params: 'user[avatar]',
error_message: 'invalid field: "[]"'
it_behaves_like 'rejecting the invalid key',
key_in_header: 'x' * 11000,
key_in_upload_params: 'user[avatar]',
error_message: "invalid field: \"#{'x' * 11000}\""
end
context 'with key with unbalanced brackets in header' do
include_context 'with one temporary file for multipart'
let(:invalid_key) { 'user[avatar' }
let(:rewritten_fields) { rewritten_fields_hash( invalid_key => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'user[avatar]', filename: filename, remote_id: remote_id) }
it 'builds no UploadedFile' do
expect(app).not_to receive(:call)
expect { subject }.to raise_error(RuntimeError, "invalid field: \"#{invalid_key}\"")
end
end
end
end
end

View File

@ -27,12 +27,12 @@ RSpec.describe UploadedFile do
end
it 'handles a blank path' do
params['file.path'] = ''
params['path'] = ''
# Not a real file, so can't determine size itself
params['file.size'] = 1.byte
params['size'] = 1.byte
expect { described_class.from_params(params, :file, upload_path) }
expect { described_class.from_params(params, upload_path) }
.not_to raise_error
end
end
@ -50,7 +50,7 @@ RSpec.describe UploadedFile do
end
end
describe '.from_params_without_field' do
describe '.from_params' do
let(:upload_path) { nil }
after do
@ -58,7 +58,7 @@ RSpec.describe UploadedFile do
end
subject do
described_class.from_params_without_field(params, [upload_path, Dir.tmpdir])
described_class.from_params(params, [upload_path, Dir.tmpdir])
end
context 'when valid file is specified' do
@ -170,190 +170,6 @@ RSpec.describe UploadedFile do
end
end
end
describe '.from_params' do
let(:upload_path) { nil }
let(:file_path_override) { nil }
after do
FileUtils.rm_r(upload_path) if upload_path
end
subject do
described_class.from_params(params, :file, [upload_path, Dir.tmpdir], file_path_override)
end
RSpec.shared_context 'filepath override' do
let(:temp_file_override) { Tempfile.new(%w[override override], temp_dir) }
let(:file_path_override) { temp_file_override.path }
before do
FileUtils.touch(temp_file_override)
end
after do
FileUtils.rm_f(temp_file_override)
end
end
context 'when valid file is specified' do
context 'only local path is specified' do
let(:params) do
{ 'file.path' => temp_file.path }
end
it { is_expected.not_to be_nil }
it "generates filename from path" do
expect(subject.original_filename).to eq(::File.basename(temp_file.path))
end
end
context 'all parameters are specified' do
context 'with a filepath' do
let(:params) do
{ 'file.path' => temp_file.path,
'file.name' => 'dir/my file&.txt',
'file.type' => 'my/type',
'file.sha256' => 'sha256' }
end
it_behaves_like 'using the file path',
filename: 'my_file_.txt',
content_type: 'my/type',
sha256: 'sha256',
path_suffix: 'test'
end
context 'with a filepath override' do
include_context 'filepath override'
let(:params) do
{ 'file.path' => temp_file.path,
'file.name' => 'dir/my file&.txt',
'file.type' => 'my/type',
'file.sha256' => 'sha256' }
end
it_behaves_like 'using the file path',
filename: 'my_file_.txt',
content_type: 'my/type',
sha256: 'sha256',
path_suffix: 'override'
end
context 'with a remote id' do
let(:params) do
{
'file.name' => 'dir/my file&.txt',
'file.sha256' => 'sha256',
'file.remote_url' => 'http://localhost/file',
'file.remote_id' => '1234567890',
'file.etag' => 'etag1234567890',
'file.size' => '123456'
}
end
it_behaves_like 'using the remote id',
filename: 'my_file_.txt',
content_type: 'application/octet-stream',
sha256: 'sha256',
size: 123456,
remote_id: '1234567890'
end
context 'with a path and a remote id' do
let(:params) do
{
'file.path' => temp_file.path,
'file.name' => 'dir/my file&.txt',
'file.sha256' => 'sha256',
'file.remote_url' => 'http://localhost/file',
'file.remote_id' => '1234567890',
'file.etag' => 'etag1234567890',
'file.size' => '123456'
}
end
it_behaves_like 'using the remote id',
filename: 'my_file_.txt',
content_type: 'application/octet-stream',
sha256: 'sha256',
size: 123456,
remote_id: '1234567890'
end
context 'with a path override and a remote id' do
include_context 'filepath override'
let(:params) do
{
'file.name' => 'dir/my file&.txt',
'file.sha256' => 'sha256',
'file.remote_url' => 'http://localhost/file',
'file.remote_id' => '1234567890',
'file.etag' => 'etag1234567890',
'file.size' => '123456'
}
end
it_behaves_like 'using the remote id',
filename: 'my_file_.txt',
content_type: 'application/octet-stream',
sha256: 'sha256',
size: 123456,
remote_id: '1234567890'
end
end
end
context 'when no params are specified' do
let(:params) do
{}
end
it "does not return an object" do
is_expected.to be_nil
end
end
context 'when verifying allowed paths' do
let(:params) do
{ 'file.path' => temp_file.path }
end
context 'when file is stored in system temporary folder' do
let(:temp_dir) { Dir.tmpdir }
it "succeeds" do
is_expected.not_to be_nil
end
end
context 'when file is stored in user provided upload path' do
let(:upload_path) { Dir.mktmpdir }
let(:temp_dir) { upload_path }
it "succeeds" do
is_expected.not_to be_nil
end
end
context 'when file is stored outside of user provided upload path' do
let!(:generated_dir) { Dir.mktmpdir }
let!(:temp_dir) { Dir.mktmpdir }
before do
# We overwrite default temporary path
allow(Dir).to receive(:tmpdir).and_return(generated_dir)
end
it "raises an error" do
expect { subject }.to raise_error(UploadedFile::InvalidPathError, /insecure path used/)
end
end
end
end
end
describe '.initialize' do

View File

@ -4775,22 +4775,6 @@ RSpec.describe Ci::Build do
describe '#debug_mode?' do
subject { build.debug_mode? }
context 'when feature is disabled' do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
end
it { is_expected.to eq false }
context 'when in variables' do
before do
create(:ci_instance_variable, key: 'CI_DEBUG_TRACE', value: 'true')
end
it { is_expected.to eq false }
end
end
context 'when CI_DEBUG_TRACE=true is in variables' do
context 'when in instance variables' do
before do

View File

@ -30,6 +30,10 @@ RSpec.describe API::Invitations do
api("/#{source.model_name.plural}/#{source.id}/invitations", user)
end
def invite_member_by_email(source, source_type, email, created_by)
create(:"#{source_type}_member", invite_token: '123', invite_email: email, source: source, user: nil, created_by: created_by)
end
shared_examples 'POST /:source_type/:id/invitations' do |source_type|
context "with :source_type == #{source_type.pluralize}" do
it_behaves_like 'a 404 response when source is private' do
@ -280,10 +284,6 @@ RSpec.describe API::Invitations do
expect(json_response.first['created_by_name']).to eq(developer.name)
expect(json_response.first['user_name']).to eq(nil)
end
def invite_member_by_email(source, source_type, email, created_by)
create(:"#{source_type}_member", invite_token: '123', invite_email: email, source: source, user: nil, created_by: created_by)
end
end
end
@ -298,4 +298,80 @@ RSpec.describe API::Invitations do
let(:source) { group }
end
end
shared_examples 'DELETE /:source_type/:id/invitations/:email' do |source_type|
def invite_api(source, user, email)
api("/#{source.model_name.plural}/#{source.id}/invitations/#{email}", user)
end
context "with :source_type == #{source_type.pluralize}" do
let!(:invite) { invite_member_by_email(source, source_type, developer.email, developer) }
it_behaves_like 'a 404 response when source is private' do
let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/invitations/#{invite.invite_email}", stranger) }
end
context 'when authenticated as a non-member or member with insufficient rights' do
%i[access_requester stranger].each do |type|
context "as a #{type}" do
it 'returns 403' do
user = public_send(type)
delete invite_api(source, user, invite.invite_email)
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
end
context 'when authenticated as a member and deleting themself' do
it 'does not delete the member' do
expect do
delete invite_api(source, developer, invite.invite_email)
expect(response).to have_gitlab_http_status(:forbidden)
end.not_to change { source.members.count }
end
end
context 'when authenticated as a maintainer/owner' do
it 'deletes the member and returns 204 with no content' do
expect do
delete invite_api(source, maintainer, invite.invite_email)
expect(response).to have_gitlab_http_status(:no_content)
end.to change { source.members.count }.by(-1)
end
end
it 'returns 404 if member does not exist' do
delete invite_api(source, maintainer, non_existing_record_id)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 422 for a valid request if the resource was not destroyed' do
allow_next_instance_of(::Members::DestroyService) do |instance|
allow(instance).to receive(:execute).with(invite).and_return(invite)
end
delete invite_api(source, maintainer, invite.invite_email)
expect(response).to have_gitlab_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /projects/:id/inviations/:email' do
it_behaves_like 'DELETE /:source_type/:id/invitations/:email', 'project' do
let(:source) { project }
end
end
describe 'DELETE /groups/:id/inviations/:email' do
it_behaves_like 'DELETE /:source_type/:id/invitations/:email', 'group' do
let(:source) { group }
end
end
end

View File

@ -827,32 +827,6 @@ RSpec.describe API::Jobs do
expect(response).to have_gitlab_http_status(expected_status)
end
end
context 'with restrict_access_to_build_debug_mode feature disabled' do
before do
stub_feature_flags(restrict_access_to_build_debug_mode: false)
end
where(:public_builds, :user_project_role, :expected_status) do
true | 'developer' | :ok
true | 'guest' | :ok
false | 'developer' | :ok
false | 'guest' | :forbidden
end
with_them do
before do
project.update!(public_builds: public_builds)
project.add_role(user, user_project_role)
get api("/projects/#{project.id}/jobs/#{job.id}/trace", api_user)
end
it 'renders trace to authorized users' do
expect(response).to have_gitlab_http_status(expected_status)
end
end
end
end
end

View File

@ -13,29 +13,23 @@ module MultipartHelpers
)
end
# This function assumes a `mode` variable to be set
def upload_parameters_for(filepath: nil, key: nil, filename: 'filename', remote_id: 'remote_id')
def upload_parameters_for(filepath: nil, key: nil, mode: nil, filename: 'filename', remote_id: 'remote_id')
result = {
"#{key}.name" => filename,
"#{key}.type" => "application/octet-stream",
"#{key}.sha256" => "1234567890"
"name" => filename,
"type" => "application/octet-stream",
"sha256" => "1234567890"
}
case mode
when :local
result["#{key}.path"] = filepath
result["path"] = filepath
when :remote
result["#{key}.remote_id"] = remote_id
result["#{key}.size"] = 3.megabytes
result["remote_id"] = remote_id
result["size"] = 3.megabytes
else
raise ArgumentError, "can't handle #{mode} mode"
end
return result if ::Feature.disabled?(:upload_middleware_jwt_params_handler, default_enabled: true)
# the HandlerForJWTParams expects a jwt token with the upload parameters
# *without* the "#{key}." prefix
result.deep_transform_keys! { |k| k.remove("#{key}.") }
{
"#{key}.gitlab-workhorse-upload" => jwt_token(data: { 'upload' => result })
}

View File

@ -2,28 +2,6 @@
RSpec.shared_examples 'handling file uploads' do |shared_examples_name|
context 'with object storage disabled' do
context 'with upload_middleware_jwt_params_handler disabled' do
before do
stub_feature_flags(upload_middleware_jwt_params_handler: false)
expect_next_instance_of(Gitlab::Middleware::Multipart::Handler) do |handler|
expect(handler).to receive(:with_open_files).and_call_original
end
end
it_behaves_like shared_examples_name
end
context 'with upload_middleware_jwt_params_handler enabled' do
before do
stub_feature_flags(upload_middleware_jwt_params_handler: true)
expect_next_instance_of(Gitlab::Middleware::Multipart::HandlerForJWTParams) do |handler|
expect(handler).to receive(:with_open_files).and_call_original
end
end
it_behaves_like shared_examples_name
end
it_behaves_like shared_examples_name
end
end

View File

@ -5,7 +5,7 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
include_context 'with one temporary file for multipart'
let(:rewritten_fields) { rewritten_fields_hash('file' => uploaded_filepath) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', filename: filename, remote_id: remote_id) }
let(:params) { upload_parameters_for(filepath: uploaded_filepath, key: 'file', mode: mode, filename: filename, remote_id: remote_id) }
it 'builds an UploadedFile' do
expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(file))
@ -19,8 +19,8 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
let(:rewritten_fields) { rewritten_fields_hash('file1' => uploaded_filepath, 'file2' => uploaded_filepath2) }
let(:params) do
upload_parameters_for(filepath: uploaded_filepath, key: 'file1', filename: filename, remote_id: remote_id).merge(
upload_parameters_for(filepath: uploaded_filepath2, key: 'file2', filename: filename2, remote_id: remote_id2)
upload_parameters_for(filepath: uploaded_filepath, key: 'file1', mode: mode, filename: filename, remote_id: remote_id).merge(
upload_parameters_for(filepath: uploaded_filepath2, key: 'file2', mode: mode, filename: filename2, remote_id: remote_id2)
)
end
@ -38,7 +38,7 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
include_context 'with one temporary file for multipart'
let(:rewritten_fields) { rewritten_fields_hash('user[avatar]' => uploaded_filepath) }
let(:params) { { 'user' => { 'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } }
let(:params) { { 'user' => { 'avatar' => upload_parameters_for(filepath: uploaded_filepath, mode: mode, filename: filename, remote_id: remote_id) } } }
it 'builds an UploadedFile' do
expect_uploaded_files(filepath: uploaded_filepath, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar))
@ -54,8 +54,8 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
let(:params) do
{
'user' => {
'avatar' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id),
'screenshot' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2)
'avatar' => upload_parameters_for(filepath: uploaded_filepath, mode: mode, filename: filename, remote_id: remote_id),
'screenshot' => upload_parameters_for(filepath: uploaded_filepath2, mode: mode, filename: filename2, remote_id: remote_id2)
}
}
end
@ -74,7 +74,7 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
include_context 'with one temporary file for multipart'
let(:rewritten_fields) { rewritten_fields_hash('user[avatar][bananas]' => uploaded_filepath) }
let(:params) { { 'user' => { 'avatar' => { 'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id) } } } }
let(:params) { { 'user' => { 'avatar' => { 'bananas' => upload_parameters_for(filepath: uploaded_filepath, mode: mode, filename: filename, remote_id: remote_id) } } } }
it 'builds an UploadedFile' do
expect_uploaded_files(filepath: uploaded_file, original_filename: filename, remote_id: remote_id, size: uploaded_file.size, params_path: %w(user avatar bananas))
@ -91,10 +91,10 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
{
'user' => {
'avatar' => {
'bananas' => upload_parameters_for(filepath: uploaded_filepath, filename: filename, remote_id: remote_id)
'bananas' => upload_parameters_for(filepath: uploaded_filepath, mode: mode, filename: filename, remote_id: remote_id)
},
'friend' => {
'ananas' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2)
'ananas' => upload_parameters_for(filepath: uploaded_filepath2, mode: mode, filename: filename2, remote_id: remote_id2)
}
}
}
@ -122,11 +122,11 @@ RSpec.shared_examples 'handling all upload parameters conditions' do
end
let(:params) do
upload_parameters_for(filepath: uploaded_filepath, filename: filename, key: 'file', remote_id: remote_id).merge(
upload_parameters_for(filepath: uploaded_filepath, filename: filename, key: 'file', mode: mode, remote_id: remote_id).merge(
'user' => {
'avatar' => upload_parameters_for(filepath: uploaded_filepath2, filename: filename2, remote_id: remote_id2),
'avatar' => upload_parameters_for(filepath: uploaded_filepath2, mode: mode, filename: filename2, remote_id: remote_id2),
'friend' => {
'avatar' => upload_parameters_for(filepath: uploaded_filepath3, filename: filename3, remote_id: remote_id3)
'avatar' => upload_parameters_for(filepath: uploaded_filepath3, mode: mode, filename: filename3, remote_id: remote_id3)
}
}
)

7
tmp/.gitignore vendored
View File

@ -1,5 +1,10 @@
*
!*/
!.gitignore
!.gitkeep
# explicitly list ignored sub directories to speed up performance
/cache/
/capybara/
/letter_opener/
/rubocop_cache/
/tests/