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 { ...@@ -313,6 +313,7 @@ export default {
</div> </div>
<pipelines-filtered-search <pipelines-filtered-search
v-if="stateToRender !== $options.stateMap.emptyState"
:project-id="projectId" :project-id="projectId"
:params="validatedParams" :params="validatedParams"
@filterPipelines="filterPipelines" @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 ...@@ -187,6 +187,7 @@ module ObjectStorage
hash[:TempPath] = workhorse_local_upload_path hash[:TempPath] = workhorse_local_upload_path
end end
hash[:FeatureFlagExtractBase] = Feature.enabled?(:workhorse_extract_filename_base)
hash[:MaximumSize] = maximum_size if maximum_size.present? hash[:MaximumSize] = maximum_size if maximum_size.present?
end end
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: ...@@ -231,7 +231,7 @@ To add a Kubernetes cluster to your project, group, or instance:
name: gitlab name: gitlab
namespace: kube-system namespace: kube-system
--- ---
apiVersion: rbac.authorization.k8s.io/v1beta1 apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding kind: ClusterRoleBinding
metadata: metadata:
name: gitlab-admin name: gitlab-admin
......
...@@ -322,7 +322,7 @@ Regardless of the approval rules you choose for your project, users can edit the ...@@ -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). request, overriding the rules you set as [default](#adding--editing-a-default-approval-rule).
To prevent that from happening: 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**. 1. Click **Save changes**.
#### Resetting approvals on push #### Resetting approvals on push
......
...@@ -36,10 +36,14 @@ export default { ...@@ -36,10 +36,14 @@ export default {
preventAuthorApprovalDocsAnchor: preventAuthorApprovalDocsAnchor:
'allowing-merge-request-authors-to-approve-their-own-merge-requests', 'allowing-merge-request-authors-to-approve-their-own-merge-requests',
requireUserPasswordDocsAnchor: 'require-authentication-when-approving-a-merge-request', requireUserPasswordDocsAnchor: 'require-authentication-when-approving-a-merge-request',
removeApprovalsOnPushDocsAnchor: 'resetting-approvals-on-push',
}, },
i18n: { i18n: {
authorApprovalLabel: __('Prevent MR approvals by the author.'), authorApprovalLabel: __('Prevent MR approvals by the author.'),
requireUserPasswordLabel: __('Require user password for approvals.'), 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'), saveChanges: __('Save changes'),
}, },
}; };
...@@ -60,6 +64,12 @@ export default { ...@@ -60,6 +64,12 @@ export default {
:anchor="$options.links.requireUserPasswordDocsAnchor" :anchor="$options.links.requireUserPasswordDocsAnchor"
data-testid="require-user-password" 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-form-group>
<gl-button type="submit" variant="success" category="primary" :disabled="isLoading"> <gl-button type="submit" variant="success" category="primary" :disabled="isLoading">
{{ $options.i18n.saveChanges }} {{ $options.i18n.saveChanges }}
......
...@@ -27,6 +27,7 @@ export const updateSettings = ({ commit, state }, endpoint) => { ...@@ -27,6 +27,7 @@ export const updateSettings = ({ commit, state }, endpoint) => {
const payload = { const payload = {
allow_author_approval: !state.settings.preventAuthorApproval, allow_author_approval: !state.settings.preventAuthorApproval,
require_password_to_approve: state.settings.requireUserPassword, require_password_to_approve: state.settings.requireUserPassword,
retain_approvals_on_push: !state.settings.removeApprovalsOnPush,
}; };
commit(types.REQUEST_UPDATE_SETTINGS); commit(types.REQUEST_UPDATE_SETTINGS);
......
...@@ -7,6 +7,7 @@ export default { ...@@ -7,6 +7,7 @@ export default {
[types.RECEIVE_SETTINGS_SUCCESS](state, data) { [types.RECEIVE_SETTINGS_SUCCESS](state, data) {
state.settings.preventAuthorApproval = !data.allow_author_approval; state.settings.preventAuthorApproval = !data.allow_author_approval;
state.settings.requireUserPassword = data.require_password_to_approve; state.settings.requireUserPassword = data.require_password_to_approve;
state.settings.removeApprovalsOnPush = !data.retain_approvals_on_push;
state.isLoading = false; state.isLoading = false;
}, },
[types.RECEIVE_SETTINGS_ERROR](state) { [types.RECEIVE_SETTINGS_ERROR](state) {
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
[types.UPDATE_SETTINGS_SUCCESS](state, data) { [types.UPDATE_SETTINGS_SUCCESS](state, data) {
state.settings.preventAuthorApproval = !data.allow_author_approval; state.settings.preventAuthorApproval = !data.allow_author_approval;
state.settings.requireUserPassword = data.require_password_to_approve; state.settings.requireUserPassword = data.require_password_to_approve;
state.settings.removeApprovalsOnPush = !data.retain_approvals_on_push;
state.isLoading = false; state.isLoading = false;
}, },
[types.UPDATE_SETTINGS_ERROR](state) { [types.UPDATE_SETTINGS_ERROR](state) {
......
...@@ -8,7 +8,6 @@ query groupEpics( ...@@ -8,7 +8,6 @@ query groupEpics(
$authorUsername: String $authorUsername: String
$labelName: [String!] $labelName: [String!]
$milestoneTitle: String = "" $milestoneTitle: String = ""
$myReactionEmoji: String
$confidential: Boolean $confidential: Boolean
$search: String = "" $search: String = ""
$sortBy: EpicSort $sortBy: EpicSort
...@@ -23,7 +22,6 @@ query groupEpics( ...@@ -23,7 +22,6 @@ query groupEpics(
authorUsername: $authorUsername authorUsername: $authorUsername
labelName: $labelName labelName: $labelName
milestoneTitle: $milestoneTitle milestoneTitle: $milestoneTitle
myReactionEmoji: $myReactionEmoji
confidential: $confidential confidential: $confidential
search: $search search: $search
sort: $sortBy sort: $sortBy
......
...@@ -5,7 +5,6 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -5,7 +5,6 @@ import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.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 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'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
...@@ -15,7 +14,7 @@ export default { ...@@ -15,7 +14,7 @@ export default {
inject: ['groupFullPath', 'groupMilestonesPath'], inject: ['groupFullPath', 'groupMilestonesPath'],
computed: { computed: {
urlParams() { urlParams() {
const { search, authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji } = const { search, authorUsername, labelName, milestoneTitle, confidential } =
this.filterParams || {}; this.filterParams || {};
return { return {
...@@ -28,14 +27,13 @@ export default { ...@@ -28,14 +27,13 @@ export default {
'label_name[]': labelName, 'label_name[]': labelName,
milestone_title: milestoneTitle, milestone_title: milestoneTitle,
confidential, confidential,
my_reaction_emoji: myReactionEmoji,
search, search,
}; };
}, },
}, },
methods: { methods: {
getFilteredSearchTokens() { getFilteredSearchTokens() {
const tokens = [ return [
{ {
type: 'author_username', type: 'author_username',
icon: 'user', icon: 'user',
...@@ -105,35 +103,9 @@ export default { ...@@ -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() { getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, myReactionEmoji, search } = const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {}; this.filterParams || {};
const filteredSearchValue = []; const filteredSearchValue = [];
...@@ -167,13 +139,6 @@ export default { ...@@ -167,13 +139,6 @@ export default {
}); });
} }
if (myReactionEmoji) {
filteredSearchValue.push({
type: 'my_reaction_emoji',
value: { data: myReactionEmoji },
});
}
if (search) { if (search) {
filteredSearchValue.push(search); filteredSearchValue.push(search);
} }
...@@ -199,9 +164,6 @@ export default { ...@@ -199,9 +164,6 @@ export default {
case 'confidential': case 'confidential':
filterParams.confidential = filter.value.data; filterParams.confidential = filter.value.data;
break; break;
case 'my_reaction_emoji':
filterParams.myReactionEmoji = filter.value.data;
break;
case 'filtered-search-term': case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data); if (filter.value.data) plainText.push(filter.value.data);
break; break;
......
...@@ -9,7 +9,6 @@ query groupEpics( ...@@ -9,7 +9,6 @@ query groupEpics(
$labelName: [String!] = [] $labelName: [String!] = []
$authorUsername: String = "" $authorUsername: String = ""
$milestoneTitle: String = "" $milestoneTitle: String = ""
$myReactionEmoji: String
$confidential: Boolean $confidential: Boolean
$search: String = "" $search: String = ""
$first: Int = 1001 $first: Int = 1001
...@@ -25,7 +24,6 @@ query groupEpics( ...@@ -25,7 +24,6 @@ query groupEpics(
labelName: $labelName labelName: $labelName
authorUsername: $authorUsername authorUsername: $authorUsername
milestoneTitle: $milestoneTitle milestoneTitle: $milestoneTitle
myReactionEmoji: $myReactionEmoji
confidential: $confidential confidential: $confidential
search: $search search: $search
first: $first first: $first
......
...@@ -33,6 +33,12 @@ class Groups::Analytics::CycleAnalyticsController < Groups::Analytics::Applicati ...@@ -33,6 +33,12 @@ class Groups::Analytics::CycleAnalyticsController < Groups::Analytics::Applicati
def load_value_stream def load_value_stream
return unless @group && params[:value_stream_id] 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
end end
...@@ -50,10 +50,6 @@ module Resolvers ...@@ -50,10 +50,6 @@ module Resolvers
required: false, required: false,
description: 'Filter epics by given confidentiality.' 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 type Types::EpicType, null: true
def ready?(**args) def ready?(**args)
......
...@@ -13,4 +13,4 @@ ...@@ -13,4 +13,4 @@
.gl-form-checkbox.custom-control.custom-checkbox .gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :disable_overriding_approvers_per_merge_request , class: 'custom-control-input' = 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 = 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 @@ ...@@ -24,9 +24,9 @@
.gl-form-checkbox-group .gl-form-checkbox-group
.gl-form-checkbox.custom-control.custom-checkbox .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 = 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' = 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 .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 ...@@ -35,6 +35,27 @@ RSpec.describe Groups::Analytics::CycleAnalyticsController do
expect(response).to render_template :show expect(response).to render_template :show
end 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 end
context 'when the license is missing' do context 'when the license is missing' do
......
...@@ -22,7 +22,7 @@ RSpec.describe 'Admin interacts with merge requests approvals settings' do ...@@ -22,7 +22,7 @@ RSpec.describe 'Admin interacts with merge requests approvals settings' do
page.within('.merge-request-approval-settings') do page.within('.merge-request-approval-settings') do
check 'Prevent MR approvals by author.' check 'Prevent MR approvals by author.'
check 'Prevent MR approvals from users who make commits to the MR.' 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') click_button('Save changes')
end end
...@@ -30,15 +30,14 @@ RSpec.describe 'Admin interacts with merge requests approvals settings' do ...@@ -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 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 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) visit edit_project_path(project)
page.within('#js-merge-request-approval-settings') do 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_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_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')).to be_disabled.and be_checked
expect(find('#project_disable_overriding_approvers_per_merge_request')).not_to be_checked
end end
end end
end end
...@@ -53,6 +53,7 @@ describe('ApprovalSettings', () => { ...@@ -53,6 +53,7 @@ describe('ApprovalSettings', () => {
testid | setting | label | anchor testid | setting | label | anchor
${'prevent-author-approval'} | ${'preventAuthorApproval'} | ${'Prevent MR approvals by the author.'} | ${'allowing-merge-request-authors-to-approve-their-own-merge-requests'} ${'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'} ${'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 }) => { `('with $testid checkbox', ({ testid, setting, label, anchor }) => {
let checkbox = null; let checkbox = null;
......
...@@ -75,13 +75,18 @@ describe('EE approvals group settings module actions', () => { ...@@ -75,13 +75,18 @@ describe('EE approvals group settings module actions', () => {
settings: { settings: {
preventAuthorApproval: false, preventAuthorApproval: false,
requireUserPassword: false, requireUserPassword: false,
removeApprovalsOnPush: false,
}, },
}; };
}); });
describe('on success', () => { describe('on success', () => {
it('dispatches the request and updates payload', () => { 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); mock.onPut(approvalSettingsPath).replyOnce(httpStatus.OK, data);
return testAction( return testAction(
......
...@@ -21,10 +21,12 @@ describe('Group settings store mutations', () => { ...@@ -21,10 +21,12 @@ describe('Group settings store mutations', () => {
mutations.RECEIVE_SETTINGS_SUCCESS(state, { mutations.RECEIVE_SETTINGS_SUCCESS(state, {
allow_author_approval: true, allow_author_approval: true,
require_password_to_approve: true, require_password_to_approve: true,
retain_approvals_on_push: true,
}); });
expect(state.settings.preventAuthorApproval).toBe(false); expect(state.settings.preventAuthorApproval).toBe(false);
expect(state.settings.requireUserPassword).toBe(true); expect(state.settings.requireUserPassword).toBe(true);
expect(state.settings.removeApprovalsOnPush).toBe(false);
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
}); });
}); });
...@@ -50,10 +52,12 @@ describe('Group settings store mutations', () => { ...@@ -50,10 +52,12 @@ describe('Group settings store mutations', () => {
mutations.UPDATE_SETTINGS_SUCCESS(state, { mutations.UPDATE_SETTINGS_SUCCESS(state, {
allow_author_approval: true, allow_author_approval: true,
require_password_to_approve: true, require_password_to_approve: true,
retain_approvals_on_push: true,
}); });
expect(state.settings.preventAuthorApproval).toBe(false); expect(state.settings.preventAuthorApproval).toBe(false);
expect(state.settings.requireUserPassword).toBe(true); expect(state.settings.requireUserPassword).toBe(true);
expect(state.settings.removeApprovalsOnPush).toBe(false);
expect(state.isLoading).toBe(false); expect(state.isLoading).toBe(false);
}); });
}); });
......
...@@ -12,7 +12,6 @@ import { TEST_HOST } from 'helpers/test_constants'; ...@@ -12,7 +12,6 @@ import { TEST_HOST } from 'helpers/test_constants';
import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { visitUrl, mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; 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 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 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'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
...@@ -160,9 +159,18 @@ describe('RoadmapFilters', () => { ...@@ -160,9 +159,18 @@ describe('RoadmapFilters', () => {
]; ];
let filteredSearchBar; let filteredSearchBar;
const operators = [{ value: '=', description: 'is', default: 'true' }]; beforeEach(() => {
filteredSearchBar = wrapper.find(FilteredSearchBar);
});
it('component is rendered with correct namespace & recent search key', () => {
expect(filteredSearchBar.exists()).toBe(true);
expect(filteredSearchBar.props('namespace')).toBe('gitlab-org');
expect(filteredSearchBar.props('recentSearchesStorageKey')).toBe('epics');
});
const filterTokens = [ it('includes `Author` and `Label` tokens', () => {
expect(filteredSearchBar.props('tokens')).toEqual([
{ {
type: 'author_username', type: 'author_username',
icon: 'user', icon: 'user',
...@@ -170,7 +178,7 @@ describe('RoadmapFilters', () => { ...@@ -170,7 +178,7 @@ describe('RoadmapFilters', () => {
unique: true, unique: true,
symbol: '@', symbol: '@',
token: AuthorToken, token: AuthorToken,
operators, operators: [{ value: '=', description: 'is', default: 'true' }],
fetchAuthors: expect.any(Function), fetchAuthors: expect.any(Function),
}, },
{ {
...@@ -180,7 +188,7 @@ describe('RoadmapFilters', () => { ...@@ -180,7 +188,7 @@ describe('RoadmapFilters', () => {
unique: false, unique: false,
symbol: '~', symbol: '~',
token: LabelToken, token: LabelToken,
operators, operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: expect.any(Function), fetchLabels: expect.any(Function),
}, },
{ {
...@@ -190,7 +198,7 @@ describe('RoadmapFilters', () => { ...@@ -190,7 +198,7 @@ describe('RoadmapFilters', () => {
unique: true, unique: true,
symbol: '%', symbol: '%',
token: MilestoneToken, token: MilestoneToken,
operators, operators: [{ value: '=', description: 'is', default: 'true' }],
fetchMilestones: expect.any(Function), fetchMilestones: expect.any(Function),
}, },
{ {
...@@ -199,26 +207,13 @@ describe('RoadmapFilters', () => { ...@@ -199,26 +207,13 @@ describe('RoadmapFilters', () => {
title: 'Confidential', title: 'Confidential',
unique: true, unique: true,
token: GlFilteredSearchToken, token: GlFilteredSearchToken,
operators, operators: [{ value: '=', description: 'is', default: 'true' }],
options: [ options: [
{ icon: 'eye-slash', value: true, title: 'Yes' }, { icon: 'eye-slash', value: true, title: 'Yes' },
{ icon: 'eye', value: false, title: 'No' }, { icon: 'eye', value: false, title: 'No' },
], ],
}, },
]; ]);
beforeEach(() => {
filteredSearchBar = wrapper.find(FilteredSearchBar);
});
it('component is rendered with correct namespace & recent search key', () => {
expect(filteredSearchBar.exists()).toBe(true);
expect(filteredSearchBar.props('namespace')).toBe('gitlab-org');
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 "Start date" and "Due date" sort options', () => { it('includes "Start date" and "Due date" sort options', () => {
...@@ -287,27 +282,6 @@ describe('RoadmapFilters', () => { ...@@ -287,27 +282,6 @@ describe('RoadmapFilters', () => {
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc'); expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
expect(wrapper.vm.fetchEpics).toHaveBeenCalled(); 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 ...@@ -134,17 +134,6 @@ RSpec.describe Resolvers::EpicsResolver do
end end
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 context 'with milestone_title' do
let_it_be(:milestone1) { create(:milestone, group: group) } let_it_be(:milestone1) { create(:milestone, group: group) }
......
...@@ -3155,9 +3155,6 @@ msgstr "" ...@@ -3155,9 +3155,6 @@ msgstr ""
msgid "Allow only the selected protocols to be used for Git access." msgid "Allow only the selected protocols to be used for Git access."
msgstr "" msgstr ""
msgid "Allow overrides to approval lists per merge request (MR)"
msgstr ""
msgid "Allow owners to manage default branch protection per group" msgid "Allow owners to manage default branch protection per group"
msgstr "" msgstr ""
...@@ -23297,7 +23294,10 @@ msgstr "" ...@@ -23297,7 +23294,10 @@ msgstr ""
msgid "Prevent users from changing their profile name" msgid "Prevent users from changing their profile name"
msgstr "" 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 "" msgstr ""
msgid "Prevent users from performing write operations on GitLab while performing maintenance." msgid "Prevent users from performing write operations on GitLab while performing maintenance."
...@@ -25539,6 +25539,9 @@ msgstr "" ...@@ -25539,6 +25539,9 @@ msgstr ""
msgid "Remove Zoom meeting" msgid "Remove Zoom meeting"
msgstr "" 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)" msgid "Remove all or specific assignee(s)"
msgstr "" msgstr ""
...@@ -30723,9 +30726,6 @@ msgstr "" ...@@ -30723,9 +30726,6 @@ msgstr ""
msgid "There was a problem fetching branches." msgid "There was a problem fetching branches."
msgstr "" msgstr ""
msgid "There was a problem fetching emojis."
msgstr ""
msgid "There was a problem fetching groups." msgid "There was a problem fetching groups."
msgstr "" msgstr ""
......
...@@ -39,7 +39,7 @@ module QA ...@@ -39,7 +39,7 @@ module QA
ssh_key.remove_via_api! ssh_key.remove_via_api!
end 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 = Resource::Repository::Push.fabricate! do |push|
push.repository_http_uri = repository_uri_http push.repository_http_uri = repository_uri_http
push.file_name = new_file push.file_name = new_file
...@@ -70,7 +70,7 @@ module QA ...@@ -70,7 +70,7 @@ module QA
snippet.remove_via_api! snippet.remove_via_api!
end 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 = Resource::Repository::Push.fabricate! do |push|
push.repository_ssh_uri = repository_uri_ssh push.repository_ssh_uri = repository_uri_ssh
push.ssh_key = ssh_key push.ssh_key = ssh_key
......
...@@ -515,6 +515,10 @@ describe('Pipelines', () => { ...@@ -515,6 +515,10 @@ describe('Pipelines', () => {
expect(findEmptyState().text()).toBe('There are currently no 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 () => { it('renders tab empty state finished scope', async () => {
mock.onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }).reply(200, { mock.onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }).reply(200, {
pipelines: [], pipelines: [],
...@@ -547,6 +551,10 @@ describe('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', () => { it('does not render tabs nor buttons', () => {
expect(findNavigationTabs().exists()).toBe(false); expect(findNavigationTabs().exists()).toBe(false);
expect(findTab('all').exists()).toBe(false); expect(findTab('all').exists()).toBe(false);
......
...@@ -3,7 +3,6 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue ...@@ -3,7 +3,6 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue
import Api from '~/api'; import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; 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 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 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'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
...@@ -60,16 +59,6 @@ export const mockMilestones = [ ...@@ -60,16 +59,6 @@ export const mockMilestones = [
mockEscapedMilestone, mockEscapedMilestone,
]; ];
export const mockEmoji1 = {
name: 'thumbsup',
};
export const mockEmoji2 = {
name: 'star',
};
export const mockEmojis = [mockEmoji1, mockEmoji2];
export const mockBranchToken = { export const mockBranchToken = {
type: 'source_branch', type: 'source_branch',
icon: 'branch', icon: 'branch',
...@@ -114,16 +103,6 @@ export const mockMilestoneToken = { ...@@ -114,16 +103,6 @@ export const mockMilestoneToken = {
fetchMilestones: () => Promise.resolve({ data: mockMilestones }), 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 = { export const mockMembershipToken = {
type: 'with_inherited_permissions', type: 'with_inherited_permissions',
icon: 'group', 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 ...@@ -441,6 +441,22 @@ RSpec.describe ObjectStorage do
end end
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 shared_examples 'uses local storage' do
it_behaves_like 'returns the maximum size given' do it_behaves_like 'returns the maximum size given' do
it "returns temporary path" do it "returns temporary path" do
...@@ -502,6 +518,7 @@ RSpec.describe ObjectStorage do ...@@ -502,6 +518,7 @@ RSpec.describe ObjectStorage do
end end
it_behaves_like 'uses local storage' it_behaves_like 'uses local storage'
it_behaves_like 'extracts base filename'
end end
context 'when object storage is enabled' do context 'when object storage is enabled' do
...@@ -509,6 +526,8 @@ RSpec.describe ObjectStorage do ...@@ -509,6 +526,8 @@ RSpec.describe ObjectStorage do
allow(Gitlab.config.uploads.object_store).to receive(:enabled) { true } allow(Gitlab.config.uploads.object_store).to receive(:enabled) { true }
end end
it_behaves_like 'extracts base filename'
context 'when direct upload is enabled' do context 'when direct upload is enabled' do
before do before do
allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { true } allow(Gitlab.config.uploads.object_store).to receive(:direct_upload) { true }
......
...@@ -149,6 +149,8 @@ type Response struct { ...@@ -149,6 +149,8 @@ type Response struct {
ProcessLsifReferences bool ProcessLsifReferences bool
// The maximum accepted size in bytes of the upload // The maximum accepted size in bytes of the upload
MaximumSize int64 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 // singleJoiningSlash is taken from reverseproxy.go:singleJoiningSlash
......
...@@ -63,6 +63,8 @@ type SaveFileOpts struct { ...@@ -63,6 +63,8 @@ type SaveFileOpts struct {
PresignedCompleteMultipart string PresignedCompleteMultipart string
// PresignedAbortMultipart is a presigned URL for AbortMultipartUpload // PresignedAbortMultipart is a presigned URL for AbortMultipartUpload
PresignedAbortMultipart string 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 // UseWorkhorseClientEnabled checks if the options require direct access to object storage
...@@ -88,6 +90,7 @@ func GetOpts(apiResponse *api.Response) (*SaveFileOpts, error) { ...@@ -88,6 +90,7 @@ func GetOpts(apiResponse *api.Response) (*SaveFileOpts, error) {
} }
opts := SaveFileOpts{ opts := SaveFileOpts{
FeatureFlagExtractBase: apiResponse.FeatureFlagExtractBase,
LocalTempPath: apiResponse.TempPath, LocalTempPath: apiResponse.TempPath,
RemoteID: apiResponse.RemoteObject.ID, RemoteID: apiResponse.RemoteObject.ID,
RemoteURL: apiResponse.RemoteObject.GetURL, RemoteURL: apiResponse.RemoteObject.GetURL,
......
...@@ -61,9 +61,14 @@ func TestGetOpts(t *testing.T) { ...@@ -61,9 +61,14 @@ func TestGetOpts(t *testing.T) {
multipart *api.MultipartUploadParams multipart *api.MultipartUploadParams
customPutHeaders bool customPutHeaders bool
putHeaders map[string]string putHeaders map[string]string
FeatureFlagExtractBase bool
}{ }{
{ {
name: "Single upload", name: "Single upload",
},
{
name: "Single upload w/ FeatureFlagExtractBase enabled",
FeatureFlagExtractBase: true,
}, { }, {
name: "Multipart upload", name: "Multipart upload",
multipart: &api.MultipartUploadParams{ multipart: &api.MultipartUploadParams{
...@@ -93,6 +98,7 @@ func TestGetOpts(t *testing.T) { ...@@ -93,6 +98,7 @@ func TestGetOpts(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
apiResponse := &api.Response{ apiResponse := &api.Response{
FeatureFlagExtractBase: test.FeatureFlagExtractBase,
RemoteObject: api.RemoteObject{ RemoteObject: api.RemoteObject{
Timeout: 10, Timeout: 10,
ID: "id", ID: "id",
...@@ -108,6 +114,7 @@ func TestGetOpts(t *testing.T) { ...@@ -108,6 +114,7 @@ func TestGetOpts(t *testing.T) {
opts, err := filestore.GetOpts(apiResponse) opts, err := filestore.GetOpts(apiResponse)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, apiResponse.FeatureFlagExtractBase, opts.FeatureFlagExtractBase)
require.Equal(t, apiResponse.TempPath, opts.LocalTempPath) require.Equal(t, apiResponse.TempPath, opts.LocalTempPath)
require.WithinDuration(t, deadline, opts.Deadline, time.Second) require.WithinDuration(t, deadline, opts.Deadline, time.Second)
require.Equal(t, apiResponse.RemoteObject.ID, opts.RemoteID) require.Equal(t, apiResponse.RemoteObject.ID, opts.RemoteID)
......
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"io/ioutil" "io/ioutil"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
...@@ -114,6 +115,10 @@ func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipa ...@@ -114,6 +115,10 @@ func (rew *rewriter) handleFilePart(ctx context.Context, name string, p *multipa
filename := p.FileName() filename := p.FileName()
if opts.FeatureFlagExtractBase {
filename = filepath.Base(filename)
}
if strings.Contains(filename, "/") || filename == "." || filename == ".." { if strings.Contains(filename, "/") || filename == "." || filename == ".." {
return fmt.Errorf("illegal filename: %q", filename) return fmt.Errorf("illegal filename: %q", filename)
} }
......
...@@ -327,12 +327,18 @@ func TestInvalidFileNames(t *testing.T) { ...@@ -327,12 +327,18 @@ func TestInvalidFileNames(t *testing.T) {
for _, testCase := range []struct { for _, testCase := range []struct {
filename string filename string
code int code int
FeatureFlagExtractBase bool
expectedPrefix string
}{ }{
{"foobar", 200}, // sanity check for test setup below {"foobar", 200, false, "foobar"}, // sanity check for test setup below
{"foo/bar", 500}, {"foo/bar", 500, false, ""},
{"/../../foobar", 500}, {"foo/bar", 200, true, "bar"},
{".", 500}, {"foo/bar/baz", 200, true, "baz"},
{"..", 500}, {"/../../foobar", 500, false, ""},
{"/../../foobar", 200, true, "foobar"},
{".", 500, false, ""},
{"..", 500, false, ""},
{"./", 500, false, ""},
} { } {
buffer := &bytes.Buffer{} buffer := &bytes.Buffer{}
...@@ -350,10 +356,12 @@ func TestInvalidFileNames(t *testing.T) { ...@@ -350,10 +356,12 @@ func TestInvalidFileNames(t *testing.T) {
apiResponse := &api.Response{TempPath: tempPath} apiResponse := &api.Response{TempPath: tempPath}
preparer := &DefaultPreparer{} preparer := &DefaultPreparer{}
opts, _, err := preparer.Prepare(apiResponse) opts, _, err := preparer.Prepare(apiResponse)
opts.FeatureFlagExtractBase = testCase.FeatureFlagExtractBase
require.NoError(t, err) require.NoError(t, err)
HandleFileUploads(response, httpRequest, nilHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts) HandleFileUploads(response, httpRequest, nilHandler, apiResponse, &SavedFileTracker{Request: httpRequest}, opts)
require.Equal(t, testCase.code, response.Code) 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