Commit b52bf0ef authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 26411d8f beba9e68
......@@ -313,6 +313,7 @@ export default {
</div>
<pipelines-filtered-search
v-if="stateToRender !== $options.stateMap.emptyState"
:project-id="projectId"
:params="validatedParams"
@filterPipelines="filterPipelines"
......
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
emojis: this.config.initialEmojis || [],
defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
loading: true,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeEmoji() {
return this.emojis.find(
(emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue),
);
},
},
methods: {
fetchEmojiBySearchTerm(searchTerm) {
this.loading = true;
this.config
.fetchEmojis(searchTerm)
.then((res) => {
this.emojis = Array.isArray(res) ? res : res.data;
})
.catch(() => createFlash(__('There was a problem fetching emojis.')))
.finally(() => {
this.loading = false;
});
},
searchEmojis: debounce(function debouncedSearch({ data }) {
this.fetchEmojiBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchEmojis"
>
<template #view="{ inputValue }">
<gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" />
<span v-else>{{ inputValue }}</span>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="emoji in defaultEmojis"
:key="emoji.value"
:value="emoji.value"
>
{{ emoji.value }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultEmojis.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="emoji in emojis"
:key="emoji.name"
:value="emoji.name"
>
<div class="gl-display-flex">
<gl-emoji :data-name="emoji.name" />
<span class="gl-ml-3">{{ emoji.name }}</span>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -187,6 +187,7 @@ module ObjectStorage
hash[:TempPath] = workhorse_local_upload_path
end
hash[:FeatureFlagExtractBase] = Feature.enabled?(:workhorse_extract_filename_base)
hash[:MaximumSize] = maximum_size if maximum_size.present?
end
end
......
---
title: Updated documented K8s snippet to undeprecated API
merge_request: 57100
author: Raimund Hook (@stingrayza)
type: other
---
title: Hide pipeline filtered search when no pipeline exists
merge_request: 57881
author:
type: changed
---
name: workhorse_extract_filename_base
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57889
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326379
milestone: '13.11'
type: development
group: group::source code
default_enabled: false
......@@ -231,7 +231,7 @@ To add a Kubernetes cluster to your project, group, or instance:
name: gitlab
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1beta1
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: gitlab-admin
......
......@@ -322,7 +322,7 @@ Regardless of the approval rules you choose for your project, users can edit the
request, overriding the rules you set as [default](#adding--editing-a-default-approval-rule).
To prevent that from happening:
1. Uncheck the **Allow overrides to approval lists per merge request (MR).** checkbox.
1. Select the **Prevent users from modifying MR approval rules in merge requests.** checkbox.
1. Click **Save changes**.
#### Resetting approvals on push
......
......@@ -36,10 +36,14 @@ export default {
preventAuthorApprovalDocsAnchor:
'allowing-merge-request-authors-to-approve-their-own-merge-requests',
requireUserPasswordDocsAnchor: 'require-authentication-when-approving-a-merge-request',
removeApprovalsOnPushDocsAnchor: 'resetting-approvals-on-push',
},
i18n: {
authorApprovalLabel: __('Prevent MR approvals by the author.'),
requireUserPasswordLabel: __('Require user password for approvals.'),
removeApprovalsOnPushLabel: __(
'Remove all approvals in a merge request when new commits are pushed to its source branch.',
),
saveChanges: __('Save changes'),
},
};
......@@ -60,6 +64,12 @@ export default {
:anchor="$options.links.requireUserPasswordDocsAnchor"
data-testid="require-user-password"
/>
<approval-settings-checkbox
v-model="settings.removeApprovalsOnPush"
:label="$options.i18n.removeApprovalsOnPushLabel"
:anchor="$options.links.removeApprovalsOnPushDocsAnchor"
data-testid="remove-approvals-on-push"
/>
</gl-form-group>
<gl-button type="submit" variant="success" category="primary" :disabled="isLoading">
{{ $options.i18n.saveChanges }}
......
......@@ -27,6 +27,7 @@ export const updateSettings = ({ commit, state }, endpoint) => {
const payload = {
allow_author_approval: !state.settings.preventAuthorApproval,
require_password_to_approve: state.settings.requireUserPassword,
retain_approvals_on_push: !state.settings.removeApprovalsOnPush,
};
commit(types.REQUEST_UPDATE_SETTINGS);
......
......@@ -7,6 +7,7 @@ export default {
[types.RECEIVE_SETTINGS_SUCCESS](state, data) {
state.settings.preventAuthorApproval = !data.allow_author_approval;
state.settings.requireUserPassword = data.require_password_to_approve;
state.settings.removeApprovalsOnPush = !data.retain_approvals_on_push;
state.isLoading = false;
},
[types.RECEIVE_SETTINGS_ERROR](state) {
......@@ -18,6 +19,7 @@ export default {
[types.UPDATE_SETTINGS_SUCCESS](state, data) {
state.settings.preventAuthorApproval = !data.allow_author_approval;
state.settings.requireUserPassword = data.require_password_to_approve;
state.settings.removeApprovalsOnPush = !data.retain_approvals_on_push;
state.isLoading = false;
},
[types.UPDATE_SETTINGS_ERROR](state) {
......
......@@ -8,7 +8,6 @@ query groupEpics(
$authorUsername: String
$labelName: [String!]
$milestoneTitle: String = ""
$myReactionEmoji: String
$confidential: Boolean
$search: String = ""
$sortBy: EpicSort
......@@ -23,7 +22,6 @@ query groupEpics(
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
myReactionEmoji: $myReactionEmoji
confidential: $confidential
search: $search
sort: $sortBy
......
......@@ -5,7 +5,6 @@ import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
......@@ -15,7 +14,7 @@ export default {
inject: ['groupFullPath', 'groupMilestonesPath'],
computed: {
urlParams() {
const { search, authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji } =
const { search, authorUsername, labelName, milestoneTitle, confidential } =
this.filterParams || {};
return {
......@@ -28,14 +27,13 @@ export default {
'label_name[]': labelName,
milestone_title: milestoneTitle,
confidential,
my_reaction_emoji: myReactionEmoji,
search,
};
},
},
methods: {
getFilteredSearchTokens() {
const tokens = [
return [
{
type: 'author_username',
icon: 'user',
......@@ -105,35 +103,9 @@ export default {
],
},
];
if (gon.current_user_id) {
// Appending to tokens only when logged-in
tokens.push({
type: 'my_reaction_emoji',
icon: 'thumb-up',
title: __('My-Reaction'),
unique: true,
token: EmojiToken,
operators: FilterTokenOperators,
fetchEmojis: (search = '') => {
return axios
.get(`${gon.relative_url_root || ''}/-/autocomplete/award_emojis`)
.then(({ data }) => {
if (search) {
return {
data: data.filter((e) => e.name.toLowerCase().includes(search.toLowerCase())),
};
}
return { data };
});
},
});
}
return tokens;
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji, search } =
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
const filteredSearchValue = [];
......@@ -167,13 +139,6 @@ export default {
});
}
if (myReactionEmoji) {
filteredSearchValue.push({
type: 'my_reaction_emoji',
value: { data: myReactionEmoji },
});
}
if (search) {
filteredSearchValue.push(search);
}
......@@ -199,9 +164,6 @@ export default {
case 'confidential':
filterParams.confidential = filter.value.data;
break;
case 'my_reaction_emoji':
filterParams.myReactionEmoji = filter.value.data;
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
......
......@@ -9,7 +9,6 @@ query groupEpics(
$labelName: [String!] = []
$authorUsername: String = ""
$milestoneTitle: String = ""
$myReactionEmoji: String
$confidential: Boolean
$search: String = ""
$first: Int = 1001
......@@ -25,7 +24,6 @@ query groupEpics(
labelName: $labelName
authorUsername: $authorUsername
milestoneTitle: $milestoneTitle
myReactionEmoji: $myReactionEmoji
confidential: $confidential
search: $search
first: $first
......
......@@ -33,6 +33,12 @@ class Groups::Analytics::CycleAnalyticsController < Groups::Analytics::Applicati
def load_value_stream
return unless @group && params[:value_stream_id]
@value_stream = @group.value_streams.find(params[:value_stream_id])
default_name = Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME
@value_stream = if params[:value_stream_id] == default_name
@group.value_streams.new(name: default_name)
else
@group.value_streams.find(params[:value_stream_id])
end
end
end
......@@ -50,10 +50,6 @@ module Resolvers
required: false,
description: 'Filter epics by given confidentiality.'
argument :my_reaction_emoji, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by reaction emoji applied by the current user.'
type Types::EpicType, null: true
def ready?(**args)
......
......@@ -13,4 +13,4 @@
.gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :disable_overriding_approvers_per_merge_request , class: 'custom-control-input'
= f.label :disable_overriding_approvers_per_merge_request , class: 'custom-control-label' do
= _('Prevent users from modifying MR approval rules.')
= _('Prevent users from modifying MR approval rules in projects and merge requests.')
......@@ -24,9 +24,9 @@
.gl-form-checkbox-group
.gl-form-checkbox.custom-control.custom-checkbox
= form.check_box(:disable_overriding_approvers_per_merge_request, { class: 'custom-control-input', disabled: !can_modify_approvers }, false, true)
= form.check_box(:disable_overriding_approvers_per_merge_request, { class: 'custom-control-input', disabled: !can_modify_approvers })
= form.label :disable_overriding_approvers_per_merge_request, class: 'custom-control-label' do
%span= _('Allow overrides to approval lists per merge request (MR)')
%span= _('Prevent users from modifying MR approval rules in merge requests.')
= link_to sprite_icon('question-o'), help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'prevent-overriding-default-approvals'), target: '_blank'
.gl-form-checkbox.custom-control.custom-checkbox
......
---
title: Standardize input label for project-level MR rule for overriding approvers
merge_request: 57194
author:
type: changed
---
title: Fix 500 error when refreshing Value Stream Analytics page with a default stage
merge_request: 56761
author:
type: fixed
---
title: Support reaction emoji on Epics Roadmap
merge_request: 57452
author:
type: added
......@@ -35,6 +35,27 @@ RSpec.describe Groups::Analytics::CycleAnalyticsController do
expect(response).to render_template :show
end
context 'when the initial, default value stream is requested' do
let(:value_stream_id) { Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME }
before do
get(:show, params: { group_id: group, value_stream_id: value_stream_id })
end
it 'renders the default in memory value stream' do
expect(response).to have_gitlab_http_status(:ok)
expect(assigns[:value_stream].name).to eq(value_stream_id)
end
context 'when invalid name is given' do
let(:value_stream_id) { 'not_default' }
it 'renders 404 error' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
context 'when the license is missing' do
......
......@@ -22,7 +22,7 @@ RSpec.describe 'Admin interacts with merge requests approvals settings' do
page.within('.merge-request-approval-settings') do
check 'Prevent MR approvals by author.'
check 'Prevent MR approvals from users who make commits to the MR.'
check 'Prevent users from modifying MR approval rules.'
check _('Prevent users from modifying MR approval rules in projects and merge requests.')
click_button('Save changes')
end
......@@ -30,15 +30,14 @@ RSpec.describe 'Admin interacts with merge requests approvals settings' do
expect(find_field('Prevent MR approvals by author.')).to be_checked
expect(find_field('Prevent MR approvals from users who make commits to the MR.')).to be_checked
expect(find_field('Prevent users from modifying MR approval rules.')).to be_checked
expect(find_field(_('Prevent users from modifying MR approval rules in projects and merge requests.'))).to be_checked
visit edit_project_path(project)
page.within('#js-merge-request-approval-settings') do
expect(find('#project_merge_requests_author_approval')).to be_disabled.and be_checked
expect(find('#project_merge_requests_disable_committers_approval')).to be_disabled.and be_checked
expect(find('#project_disable_overriding_approvers_per_merge_request')).to be_disabled
expect(find('#project_disable_overriding_approvers_per_merge_request')).not_to be_checked
expect(find('#project_disable_overriding_approvers_per_merge_request')).to be_disabled.and be_checked
end
end
end
......@@ -50,9 +50,10 @@ describe('ApprovalSettings', () => {
});
describe.each`
testid | setting | label | anchor
${'prevent-author-approval'} | ${'preventAuthorApproval'} | ${'Prevent MR approvals by the author.'} | ${'allowing-merge-request-authors-to-approve-their-own-merge-requests'}
${'require-user-password'} | ${'requireUserPassword'} | ${'Require user password for approvals.'} | ${'require-authentication-when-approving-a-merge-request'}
testid | setting | label | anchor
${'prevent-author-approval'} | ${'preventAuthorApproval'} | ${'Prevent MR approvals by the author.'} | ${'allowing-merge-request-authors-to-approve-their-own-merge-requests'}
${'require-user-password'} | ${'requireUserPassword'} | ${'Require user password for approvals.'} | ${'require-authentication-when-approving-a-merge-request'}
${'remove-approvals-on-push'} | ${'removeApprovalsOnPush'} | ${'Remove all approvals in a merge request when new commits are pushed to its source branch.'} | ${'resetting-approvals-on-push'}
`('with $testid checkbox', ({ testid, setting, label, anchor }) => {
let checkbox = null;
......
......@@ -75,13 +75,18 @@ describe('EE approvals group settings module actions', () => {
settings: {
preventAuthorApproval: false,
requireUserPassword: false,
removeApprovalsOnPush: false,
},
};
});
describe('on success', () => {
it('dispatches the request and updates payload', () => {
const data = { allow_author_approval: true, require_password_to_approve: true };
const data = {
allow_author_approval: true,
require_password_to_approve: true,
retain_approvals_on_push: true,
};
mock.onPut(approvalSettingsPath).replyOnce(httpStatus.OK, data);
return testAction(
......
......@@ -21,10 +21,12 @@ describe('Group settings store mutations', () => {
mutations.RECEIVE_SETTINGS_SUCCESS(state, {
allow_author_approval: true,
require_password_to_approve: true,
retain_approvals_on_push: true,
});
expect(state.settings.preventAuthorApproval).toBe(false);
expect(state.settings.requireUserPassword).toBe(true);
expect(state.settings.removeApprovalsOnPush).toBe(false);
expect(state.isLoading).toBe(false);
});
});
......@@ -50,10 +52,12 @@ describe('Group settings store mutations', () => {
mutations.UPDATE_SETTINGS_SUCCESS(state, {
allow_author_approval: true,
require_password_to_approve: true,
retain_approvals_on_push: true,
});
expect(state.settings.preventAuthorApproval).toBe(false);
expect(state.settings.requireUserPassword).toBe(true);
expect(state.settings.removeApprovalsOnPush).toBe(false);
expect(state.isLoading).toBe(false);
});
});
......
......@@ -12,7 +12,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
......@@ -160,53 +159,6 @@ describe('RoadmapFilters', () => {
];
let filteredSearchBar;
const operators = [{ value: '=', description: 'is', default: 'true' }];
const filterTokens = [
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators,
fetchAuthors: expect.any(Function),
},
{
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators,
fetchLabels: expect.any(Function),
},
{
type: 'milestone_title',
icon: 'clock',
title: 'Milestone',
unique: true,
symbol: '%',
token: MilestoneToken,
operators,
fetchMilestones: expect.any(Function),
},
{
type: 'confidential',
icon: 'eye-slash',
title: 'Confidential',
unique: true,
token: GlFilteredSearchToken,
operators,
options: [
{ icon: 'eye-slash', value: true, title: 'Yes' },
{ icon: 'eye', value: false, title: 'No' },
],
},
];
beforeEach(() => {
filteredSearchBar = wrapper.find(FilteredSearchBar);
});
......@@ -217,8 +169,51 @@ describe('RoadmapFilters', () => {
expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('epics');
});
it('includes `Author`, `Milestone`, `Confidential` and `Label` tokens when user is not logged in', () => {
expect(filteredSearchBar.props('tokens')).toEqual(filterTokens);
it('includes `Author` and `Label` tokens', () => {
expect(filteredSearchBar.props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchAuthors: expect.any(Function),
},
{
type: 'label_name',
icon: 'labels',
title: 'Label',
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: expect.any(Function),
},
{
type: 'milestone_title',
icon: 'clock',
title: 'Milestone',
unique: true,
symbol: '%',
token: MilestoneToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchMilestones: expect.any(Function),
},
{
type: 'confidential',
icon: 'eye-slash',
title: 'Confidential',
unique: true,
token: GlFilteredSearchToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
options: [
{ icon: 'eye-slash', value: true, title: 'Yes' },
{ icon: 'eye', value: false, title: 'No' },
],
},
]);
});
it('includes "Start date" and "Due date" sort options', () => {
......@@ -287,27 +282,6 @@ describe('RoadmapFilters', () => {
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
});
describe('when user is logged in', () => {
beforeAll(() => {
gon.current_user_id = 1;
});
it('includes `Author`, `Milestone`, `Confidential`, `Label` and `My-Reaction` tokens', () => {
expect(filteredSearchBar.props('tokens')).toEqual([
...filterTokens,
{
type: 'my_reaction_emoji',
icon: 'thumb-up',
title: 'My-Reaction',
unique: true,
token: EmojiToken,
operators,
fetchEmojis: expect.any(Function),
},
]);
});
});
});
});
});
......@@ -134,17 +134,6 @@ RSpec.describe Resolvers::EpicsResolver do
end
end
context 'with my_reaction_emoji' do
it 'filters epics by reaction emoji' do
create(:award_emoji, name: 'thumbsup', user: current_user, awardable: epic1)
create(:award_emoji, name: 'star', user: current_user, awardable: epic2)
epics = resolve_epics(my_reaction_emoji: 'thumbsup')
expect(epics).to match_array([epic1])
end
end
context 'with milestone_title' do
let_it_be(:milestone1) { create(:milestone, group: group) }
......
......@@ -3155,9 +3155,6 @@ msgstr ""
msgid "Allow only the selected protocols to be used for Git access."
msgstr ""
msgid "Allow overrides to approval lists per merge request (MR)"
msgstr ""
msgid "Allow owners to manage default branch protection per group"
msgstr ""
......@@ -23297,7 +23294,10 @@ msgstr ""
msgid "Prevent users from changing their profile name"
msgstr ""
msgid "Prevent users from modifying MR approval rules."
msgid "Prevent users from modifying MR approval rules in merge requests."
msgstr ""
msgid "Prevent users from modifying MR approval rules in projects and merge requests."
msgstr ""
msgid "Prevent users from performing write operations on GitLab while performing maintenance."
......@@ -25539,6 +25539,9 @@ msgstr ""
msgid "Remove Zoom meeting"
msgstr ""
msgid "Remove all approvals in a merge request when new commits are pushed to its source branch."
msgstr ""
msgid "Remove all or specific assignee(s)"
msgstr ""
......@@ -30723,9 +30726,6 @@ msgstr ""
msgid "There was a problem fetching branches."
msgstr ""
msgid "There was a problem fetching emojis."
msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
......
......@@ -39,7 +39,7 @@ module QA
ssh_key.remove_via_api!
end
it 'clones, pushes, and pulls a snippet over HTTP, edits via UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/826' do
it 'clones, pushes, and pulls a snippet over HTTP, edits via UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1748' do
push = Resource::Repository::Push.fabricate! do |push|
push.repository_http_uri = repository_uri_http
push.file_name = new_file
......@@ -70,7 +70,7 @@ module QA
snippet.remove_via_api!
end
it 'clones, pushes, and pulls a snippet over SSH, deletes via UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/825' do
it 'clones, pushes, and pulls a snippet over SSH, deletes via UI', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1747' do
push = Resource::Repository::Push.fabricate! do |push|
push.repository_ssh_uri = repository_uri_ssh
push.ssh_key = ssh_key
......
......@@ -515,6 +515,10 @@ describe('Pipelines', () => {
expect(findEmptyState().text()).toBe('There are currently no pipelines.');
});
it('renders filtered search', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
it('renders tab empty state finished scope', async () => {
mock.onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }).reply(200, {
pipelines: [],
......@@ -547,6 +551,10 @@ describe('Pipelines', () => {
);
});
it('does not render filtered search', () => {
expect(findFilteredSearch().exists()).toBe(false);
});
it('does not render tabs nor buttons', () => {
expect(findNavigationTabs().exists()).toBe(false);
expect(findTab('all').exists()).toBe(false);
......
......@@ -3,7 +3,6 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
......@@ -60,16 +59,6 @@ export const mockMilestones = [
mockEscapedMilestone,
];
export const mockEmoji1 = {
name: 'thumbsup',
};
export const mockEmoji2 = {
name: 'star',
};
export const mockEmojis = [mockEmoji1, mockEmoji2];
export const mockBranchToken = {
type: 'source_branch',
icon: 'branch',
......@@ -114,16 +103,6 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
export const mockReactionEmojiToken = {
type: 'my_reaction_emoji',
icon: 'thumb-up',
title: 'My-Reaction',
unique: true,
token: EmojiToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchEmojis: () => Promise.resolve(mockEmojis),
};
export const mockMembershipToken = {
type: 'with_inherited_permissions',
icon: 'group',
......
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlFilteredSearchTokenSegment,
GlDropdownDivider,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import {
DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import { mockReactionEmojiToken, mockEmojis } from '../mock_data';
jest.mock('~/flash');
const GlEmoji = { template: '<img/>' };
const defaultStubs = {
Portal: true,
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
GlEmoji,
};
function createComponent(options = {}) {
const {
config = mockReactionEmojiToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EmojiToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
stubs,
});
}
describe('EmojiToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: mockEmojis[0].name } });
wrapper.setData({
emojis: mockEmojis,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe(mockEmojis[0].name);
});
});
describe('activeEmoji', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeEmoji).toEqual(mockEmojis[0]);
});
});
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('fetchEmojiBySearchTerm', () => {
it('calls `config.fetchEmojis` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis');
wrapper.vm.fetchEmojiBySearchTerm('foo');
expect(wrapper.vm.config.fetchEmojis).toHaveBeenCalledWith('foo');
});
it('sets response to `emojis` when request is successful', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockResolvedValue(mockEmojis);
wrapper.vm.fetchEmojiBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.emojis).toEqual(mockEmojis);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
wrapper.vm.fetchEmojiBySearchTerm('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.');
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({});
wrapper.vm.fetchEmojiBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
});
describe('template', () => {
const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
beforeEach(async () => {
wrapper = createComponent({
value: { data: `"${mockEmojis[0].name}"` },
});
wrapper.setData({
emojis: mockEmojis,
});
await wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
});
it('renders token item when value is selected', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3); // My Reaction, =, "thumbsup"
expect(tokenSegments.at(2).find(GlEmoji).attributes('data-name')).toEqual('thumbsup');
});
it('renders provided defaultEmojis as suggestions', async () => {
wrapper = createComponent({
active: true,
config: { ...mockReactionEmojiToken, defaultEmojis },
stubs: { Portal: true, GlEmoji },
});
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(defaultEmojis.length);
defaultEmojis.forEach((emoji, index) => {
expect(suggestions.at(index).text()).toBe(emoji.text);
});
});
it('does not render divider when no defaultEmojis', async () => {
wrapper = createComponent({
active: true,
config: { ...mockReactionEmojiToken, defaultEmojis: [] },
stubs: { Portal: true, GlEmoji },
});
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false);
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
it('renders `DEFAULT_LABEL_NONE` and `DEFAULT_LABEL_ANY` as default suggestions', async () => {
wrapper = createComponent({
active: true,
config: { ...mockReactionEmojiToken },
stubs: { Portal: true, GlEmoji },
});
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(2);
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_NONE.text);
expect(suggestions.at(1).text()).toBe(DEFAULT_LABEL_ANY.text);
});
});
});
......@@ -441,6 +441,22 @@ RSpec.describe ObjectStorage do
end
end
shared_examples 'extracts base filename' do
it "returns true for ExtractsBase" do
expect(subject[:FeatureFlagExtractBase]).to be true
end
context 'when workhorse_extract_filename_base is disabled' do
before do
stub_feature_flags(workhorse_extract_filename_base: false)
end
it "returns false for ExtractsBase" do
expect(subject[:FeatureFlagExtractBase]).to be false
end
end
end
shared_examples 'uses local storage' do
it_behaves_like 'returns the maximum size given' do
it "returns temporary path" do
......@@ -502,6 +518,7 @@ RSpec.describe ObjectStorage do
end
it_behaves_like 'uses local storage'
it_behaves_like 'extracts base filename'
end
context 'when object storage is enabled' do
......@@ -509,6 +526,8 @@ RSpec.describe ObjectStorage do
allow(Gitlab.config.uploads.object_store).to receive(:enabled) { true }
end
it_behaves_like 'extracts base filename'
context 'when direct upload is enabled' do
before do
allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { true }
......
......@@ -149,6 +149,8 @@ type Response struct {
ProcessLsifReferences bool
// The maximum accepted size in bytes of the upload
MaximumSize int64
// Feature flag used to determine whether to strip the multipart filename of any directories
FeatureFlagExtractBase bool
}
// singleJoiningSlash is taken from reverseproxy.go:singleJoiningSlash
......
......@@ -63,6 +63,8 @@ type SaveFileOpts struct {
PresignedCompleteMultipart string
// PresignedAbortMultipart is a presigned URL for AbortMultipartUpload
PresignedAbortMultipart string
// FeatureFlagExtractBase uses the base of the filename and strips directories
FeatureFlagExtractBase bool
}
// UseWorkhorseClientEnabled checks if the options require direct access to object storage
......@@ -88,16 +90,17 @@ func GetOpts(apiResponse *api.Response) (*SaveFileOpts, error) {
}
opts := SaveFileOpts{
LocalTempPath: apiResponse.TempPath,
RemoteID: apiResponse.RemoteObject.ID,
RemoteURL: apiResponse.RemoteObject.GetURL,
PresignedPut: apiResponse.RemoteObject.StoreURL,
PresignedDelete: apiResponse.RemoteObject.DeleteURL,
PutHeaders: apiResponse.RemoteObject.PutHeaders,
UseWorkhorseClient: apiResponse.RemoteObject.UseWorkhorseClient,
RemoteTempObjectID: apiResponse.RemoteObject.RemoteTempObjectID,
Deadline: time.Now().Add(timeout),
MaximumSize: apiResponse.MaximumSize,
FeatureFlagExtractBase: apiResponse.FeatureFlagExtractBase,
LocalTempPath: apiResponse.TempPath,
RemoteID: apiResponse.RemoteObject.ID,
RemoteURL: apiResponse.RemoteObject.GetURL,
PresignedPut: apiResponse.RemoteObject.StoreURL,
PresignedDelete: apiResponse.RemoteObject.DeleteURL,
PutHeaders: apiResponse.RemoteObject.PutHeaders,
UseWorkhorseClient: apiResponse.RemoteObject.UseWorkhorseClient,
RemoteTempObjectID: apiResponse.RemoteObject.RemoteTempObjectID,
Deadline: time.Now().Add(timeout),
MaximumSize: apiResponse.MaximumSize,
}
if opts.LocalTempPath != "" && opts.RemoteID != "" {
......
......@@ -57,13 +57,18 @@ func TestSaveFileOptsLocalAndRemote(t *testing.T) {
func TestGetOpts(t *testing.T) {
tests := []struct {
name string
multipart *api.MultipartUploadParams
customPutHeaders bool
putHeaders map[string]string
name string
multipart *api.MultipartUploadParams
customPutHeaders bool
putHeaders map[string]string
FeatureFlagExtractBase bool
}{
{
name: "Single upload",
},
{
name: "Single upload w/ FeatureFlagExtractBase enabled",
FeatureFlagExtractBase: true,
}, {
name: "Multipart upload",
multipart: &api.MultipartUploadParams{
......@@ -93,6 +98,7 @@ func TestGetOpts(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
apiResponse := &api.Response{
FeatureFlagExtractBase: test.FeatureFlagExtractBase,
RemoteObject: api.RemoteObject{
Timeout: 10,
ID: "id",
......@@ -108,6 +114,7 @@ func TestGetOpts(t *testing.T) {
opts, err := filestore.GetOpts(apiResponse)
require.NoError(t, err)
require.Equal(t, apiResponse.FeatureFlagExtractBase, opts.FeatureFlagExtractBase)
require.Equal(t, apiResponse.TempPath, opts.LocalTempPath)
require.WithinDuration(t, deadline, opts.Deadline, time.Second)
require.Equal(t, apiResponse.RemoteObject.ID, opts.RemoteID)
......
......@@ -8,6 +8,7 @@ import (
"io/ioutil"
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"github.com/prometheus/client_golang/prometheus"
......@@ -114,6 +115,10 @@ func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipa
filename := p.FileName()
if opts.FeatureFlagExtractBase {
filename = filepath.Base(filename)
}
if strings.Contains(filename, "/") || filename == "." || filename == ".." {
return fmt.Errorf("illegal filename: %q", filename)
}
......
......@@ -325,14 +325,20 @@ func TestInvalidFileNames(t *testing.T) {
defer os.RemoveAll(tempPath)
for _, testCase := range []struct {
filename string
code int
filename string
code int
FeatureFlagExtractBase bool
expectedPrefix string
}{
{"foobar", 200}, // sanity check for test setup below
{"foo/bar", 500},
{"/../../foobar", 500},
{".", 500},
{"..", 500},
{"foobar", 200, false, "foobar"}, // sanity check for test setup below
{"foo/bar", 500, false, ""},
{"foo/bar", 200, true, "bar"},
{"foo/bar/baz", 200, true, "baz"},
{"/../../foobar", 500, false, ""},
{"/../../foobar", 200, true, "foobar"},
{".", 500, false, ""},
{"..", 500, false, ""},
{"./", 500, false, ""},
} {
buffer := &bytes.Buffer{}
......@@ -350,10 +356,12 @@ func TestInvalidFileNames(t *testing.T) {
apiResponse := &api.Response{TempPath: tempPath}
preparer := &DefaultPreparer{}
opts, _, err := preparer.Prepare(apiResponse)
opts.FeatureFlagExtractBase = testCase.FeatureFlagExtractBase
require.NoError(t, err)
HandleFileUploads(response, httpRequest, nilHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts)
require.Equal(t, testCase.code, response.Code)
require.Equal(t, testCase.expectedPrefix, opts.TempFilePrefix)
}
}
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment