Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
d811b6d8f6
commit
d1377e82d9
23 changed files with 653 additions and 168 deletions
|
@ -71,10 +71,10 @@ export default {
|
|||
<gl-dropdown size="small" category="tertiary" icon="table">
|
||||
<gl-dropdown-form class="gl-px-3! gl-w-auto!">
|
||||
<div class="gl-w-auto!">
|
||||
<div v-for="c of list(maxCols)" :key="c" class="gl-display-flex">
|
||||
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
|
||||
<gl-button
|
||||
v-for="r of list(maxRows)"
|
||||
:key="r"
|
||||
v-for="c of list(maxCols)"
|
||||
:key="c"
|
||||
:data-testid="`table-${r}-${c}`"
|
||||
:class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }"
|
||||
:aria-label="getButtonLabel(r, c)"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import {
|
||||
GlFormGroup,
|
||||
GlModal,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
|
@ -12,16 +13,21 @@ import {
|
|||
import { partition, isString } from 'lodash';
|
||||
import Api from '~/api';
|
||||
import ExperimentTracking from '~/experimentation/experiment_tracking';
|
||||
import GroupSelect from '~/invite_members/components/group_select.vue';
|
||||
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
|
||||
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS } from '../constants';
|
||||
import eventHub from '../event_hub';
|
||||
import {
|
||||
responseMessageFromError,
|
||||
responseMessageFromSuccess,
|
||||
} from '../utils/response_message_parser';
|
||||
import GroupSelect from './group_select.vue';
|
||||
import MembersTokenSelect from './members_token_select.vue';
|
||||
|
||||
export default {
|
||||
name: 'InviteMembersModal',
|
||||
components: {
|
||||
GlFormGroup,
|
||||
GlDatepicker,
|
||||
GlLink,
|
||||
GlModal,
|
||||
|
@ -79,9 +85,13 @@ export default {
|
|||
selectedDate: undefined,
|
||||
groupToBeSharedWith: {},
|
||||
source: 'unknown',
|
||||
invalidFeedbackMessage: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
validationState() {
|
||||
return this.invalidFeedbackMessage === '' ? null : false;
|
||||
},
|
||||
isInviteGroup() {
|
||||
return this.inviteeType === 'group';
|
||||
},
|
||||
|
@ -142,6 +152,7 @@ export default {
|
|||
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
|
||||
},
|
||||
closeModal() {
|
||||
this.resetFields();
|
||||
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
|
||||
},
|
||||
sendInvite() {
|
||||
|
@ -150,7 +161,6 @@ export default {
|
|||
} else {
|
||||
this.submitInviteMembers();
|
||||
}
|
||||
this.closeModal();
|
||||
},
|
||||
trackInvite() {
|
||||
if (this.source === INVITE_MEMBERS_IN_COMMENT) {
|
||||
|
@ -158,12 +168,12 @@ export default {
|
|||
tracking.event('comment_invite_success');
|
||||
}
|
||||
},
|
||||
cancelInvite() {
|
||||
resetFields() {
|
||||
this.selectedAccessLevel = this.defaultAccessLevel;
|
||||
this.selectedDate = undefined;
|
||||
this.newUsersToInvite = [];
|
||||
this.groupToBeSharedWith = {};
|
||||
this.closeModal();
|
||||
this.invalidFeedbackMessage = '';
|
||||
},
|
||||
changeSelectedItem(item) {
|
||||
this.selectedAccessLevel = item;
|
||||
|
@ -175,9 +185,11 @@ export default {
|
|||
|
||||
apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id))
|
||||
.then(this.showToastMessageSuccess)
|
||||
.catch(this.showToastMessageError);
|
||||
.catch(this.showInvalidFeedbackMessage);
|
||||
},
|
||||
submitInviteMembers() {
|
||||
this.invalidFeedbackMessage = '';
|
||||
|
||||
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
|
||||
const promises = [];
|
||||
|
||||
|
@ -196,10 +208,11 @@ export default {
|
|||
|
||||
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
|
||||
}
|
||||
|
||||
this.trackInvite();
|
||||
|
||||
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
|
||||
Promise.all(promises)
|
||||
.then(this.conditionallyShowToastSuccess)
|
||||
.catch(this.showInvalidFeedbackMessage);
|
||||
},
|
||||
inviteByEmailPostData(usersToInviteByEmail) {
|
||||
return {
|
||||
|
@ -224,13 +237,27 @@ export default {
|
|||
group_access: this.selectedAccessLevel,
|
||||
};
|
||||
},
|
||||
conditionallyShowToastSuccess(response) {
|
||||
const message = responseMessageFromSuccess(response);
|
||||
|
||||
if (message === '') {
|
||||
this.showToastMessageSuccess();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.invalidFeedbackMessage = message;
|
||||
},
|
||||
showToastMessageSuccess() {
|
||||
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
|
||||
this.closeModal();
|
||||
},
|
||||
showToastMessageError(error) {
|
||||
const message = error.response.data.message || this.$options.labels.toastMessageUnsuccessful;
|
||||
|
||||
this.$toast.show(message, this.toastOptions);
|
||||
showInvalidFeedbackMessage(response) {
|
||||
this.invalidFeedbackMessage =
|
||||
responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault;
|
||||
},
|
||||
handleMembersTokenSelectClear() {
|
||||
this.invalidFeedbackMessage = '';
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
|
@ -267,8 +294,8 @@ export default {
|
|||
accessLevel: s__('InviteMembersModal|Select a role'),
|
||||
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
|
||||
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
|
||||
toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
|
||||
readMoreText: s__(`InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles.`),
|
||||
invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'),
|
||||
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
|
||||
inviteButtonText: s__('InviteMembersModal|Invite'),
|
||||
cancelButtonText: s__('InviteMembersModal|Cancel'),
|
||||
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
|
||||
|
@ -283,6 +310,7 @@ export default {
|
|||
data-qa-selector="invite_members_modal_content"
|
||||
:title="$options.labels[inviteeType].modalTitle"
|
||||
:header-close-label="$options.labels.headerCloseLabel"
|
||||
@close="resetFields"
|
||||
>
|
||||
<div>
|
||||
<p ref="introText">
|
||||
|
@ -293,15 +321,22 @@ export default {
|
|||
</gl-sprintf>
|
||||
</p>
|
||||
|
||||
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
|
||||
$options.labels[inviteeType].searchField
|
||||
}}</label>
|
||||
<div class="gl-mt-2">
|
||||
<gl-form-group
|
||||
class="gl-mt-2"
|
||||
:invalid-feedback="invalidFeedbackMessage"
|
||||
:state="validationState"
|
||||
:description="$options.labels[inviteeType].placeHolder"
|
||||
data-testid="members-form-group"
|
||||
>
|
||||
<label :id="$options.membersTokenSelectLabelId" class="col-form-label">{{
|
||||
$options.labels[inviteeType].searchField
|
||||
}}</label>
|
||||
<members-token-select
|
||||
v-if="!isInviteGroup"
|
||||
v-model="newUsersToInvite"
|
||||
:validation-state="validationState"
|
||||
:aria-labelledby="$options.membersTokenSelectLabelId"
|
||||
:placeholder="$options.labels[inviteeType].placeHolder"
|
||||
@clear="handleMembersTokenSelectClear"
|
||||
/>
|
||||
<group-select
|
||||
v-if="isInviteGroup"
|
||||
|
@ -309,7 +344,7 @@ export default {
|
|||
:groups-filter="groupSelectFilter"
|
||||
:parent-group-id="groupSelectParentId"
|
||||
/>
|
||||
</div>
|
||||
</gl-form-group>
|
||||
|
||||
<label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label>
|
||||
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
|
||||
|
@ -364,15 +399,15 @@ export default {
|
|||
|
||||
<template #modal-footer>
|
||||
<div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
|
||||
<gl-button ref="cancelButton" @click="cancelInvite">
|
||||
<gl-button data-testid="cancel-button" @click="closeModal">
|
||||
{{ $options.labels.cancelButtonText }}
|
||||
</gl-button>
|
||||
<div class="gl-mr-3"></div>
|
||||
<gl-button
|
||||
ref="inviteButton"
|
||||
:disabled="inviteDisabled"
|
||||
variant="success"
|
||||
data-qa-selector="invite_button"
|
||||
data-testid="invite-button"
|
||||
@click="sendInvite"
|
||||
>{{ $options.labels.inviteButtonText }}</gl-button
|
||||
>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui';
|
||||
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui';
|
||||
import { debounce } from 'lodash';
|
||||
import { __ } from '~/locale';
|
||||
import { getUsers } from '~/rest_api';
|
||||
|
@ -10,6 +10,7 @@ export default {
|
|||
GlTokenSelector,
|
||||
GlAvatar,
|
||||
GlAvatarLabeled,
|
||||
GlIcon,
|
||||
GlSprintf,
|
||||
},
|
||||
props: {
|
||||
|
@ -22,6 +23,11 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
validationState: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -84,6 +90,13 @@ export default {
|
|||
|
||||
this.hasBeenFocused = true;
|
||||
},
|
||||
handleTokenRemove() {
|
||||
if (this.selectedTokens.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('clear');
|
||||
},
|
||||
},
|
||||
queryOptions: { exclude_internal: true, active: true },
|
||||
i18n: {
|
||||
|
@ -95,19 +108,26 @@ export default {
|
|||
<template>
|
||||
<gl-token-selector
|
||||
v-model="selectedTokens"
|
||||
:state="validationState"
|
||||
:dropdown-items="users"
|
||||
:loading="loading"
|
||||
:allow-user-defined-tokens="emailIsValid"
|
||||
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
|
||||
:placeholder="placeholderText"
|
||||
:aria-labelledby="ariaLabelledby"
|
||||
:text-input-attrs="{
|
||||
'data-testid': 'members-token-select-input',
|
||||
'data-qa-selector': 'members_token_select_input',
|
||||
}"
|
||||
@blur="handleBlur"
|
||||
@text-input="handleTextInput"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@token-remove="handleTokenRemove"
|
||||
>
|
||||
<template #token-content="{ token }">
|
||||
<gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
|
||||
<gl-icon v-if="validationState === false" name="error" :size="16" class="gl-mr-2" />
|
||||
<gl-avatar v-else-if="token.avatar_url" :src="token.avatar_url" :size="16" />
|
||||
{{ token.name }}
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { __ } from '~/locale';
|
||||
|
||||
export const SEARCH_DELAY = 200;
|
||||
|
||||
export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
|
||||
|
@ -6,3 +8,7 @@ export const GROUP_FILTERS = {
|
|||
ALL: 'all',
|
||||
DESCENDANT_GROUPS: 'descendant_groups',
|
||||
};
|
||||
|
||||
export const API_MESSAGES = {
|
||||
EMAIL_ALREADY_INVITED: __('Invite email has already been taken'),
|
||||
};
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { isString } from 'lodash';
|
||||
import { API_MESSAGES } from '~/invite_members/constants';
|
||||
|
||||
function responseKeyedMessageParsed(keyedMessage) {
|
||||
try {
|
||||
const keys = Object.keys(keyedMessage);
|
||||
const msg = keyedMessage[keys[0]];
|
||||
|
||||
if (msg === API_MESSAGES.EMAIL_ALREADY_INVITED) {
|
||||
return '';
|
||||
}
|
||||
return msg;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
function responseMessageStringForMultiple(message) {
|
||||
return message.includes(':');
|
||||
}
|
||||
function responseMessageStringFirstPart(message) {
|
||||
return message.split(' and ')[0];
|
||||
}
|
||||
|
||||
export function responseMessageFromError(response) {
|
||||
if (!response?.response?.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const {
|
||||
response: { data },
|
||||
} = response;
|
||||
|
||||
return (
|
||||
data.error ||
|
||||
data.message?.user?.[0] ||
|
||||
data.message?.access_level?.[0] ||
|
||||
data.message?.error ||
|
||||
data.message ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function responseMessageFromSuccess(response) {
|
||||
if (!response?.[0]?.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { data } = response[0];
|
||||
|
||||
if (data.message && !data.message.user) {
|
||||
const { message } = data;
|
||||
|
||||
if (isString(message)) {
|
||||
if (responseMessageStringForMultiple(message)) {
|
||||
return responseMessageStringFirstPart(message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return responseKeyedMessageParsed(message);
|
||||
}
|
||||
|
||||
return data.message || data.message?.user || data.error || '';
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { __, s__ } from '~/locale';
|
||||
|
||||
import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
|
||||
import {
|
||||
REPORT_TYPE_SAST,
|
||||
REPORT_TYPE_DAST,
|
||||
|
@ -15,6 +14,9 @@ import {
|
|||
REPORT_TYPE_LICENSE_COMPLIANCE,
|
||||
} from '~/vue_shared/security_reports/constants';
|
||||
|
||||
import configureSastMutation from '../graphql/configure_sast.mutation.graphql';
|
||||
import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql';
|
||||
|
||||
/**
|
||||
* Translations & helpPagePaths for Static Security Configuration Page
|
||||
*/
|
||||
|
@ -214,6 +216,10 @@ export const securityFeatures = [
|
|||
helpPath: DEPENDENCY_SCANNING_HELP_PATH,
|
||||
configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH,
|
||||
type: REPORT_TYPE_DEPENDENCY_SCANNING,
|
||||
|
||||
// This field will eventually come from the backend, the progress is
|
||||
// tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621
|
||||
canEnableByMergeRequest: window.gon.features?.secDependencyScanningUiEnable,
|
||||
},
|
||||
{
|
||||
name: CONTAINER_SCANNING_NAME,
|
||||
|
@ -235,7 +241,16 @@ export const securityFeatures = [
|
|||
helpPath: SECRET_DETECTION_HELP_PATH,
|
||||
configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH,
|
||||
type: REPORT_TYPE_SECRET_DETECTION,
|
||||
|
||||
// This field is currently hardcoded because Secret Detection is always
|
||||
// available. It will eventually come from the Backend, the progress is
|
||||
// tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/333113
|
||||
available: true,
|
||||
|
||||
// This field is currently hardcoded because SAST can always be enabled via MR
|
||||
// It will eventually come from the Backend, the progress is tracked in
|
||||
// https://gitlab.com/gitlab-org/gitlab/-/issues/331621
|
||||
canEnableByMergeRequest: true,
|
||||
},
|
||||
{
|
||||
name: API_FUZZING_NAME,
|
||||
|
@ -273,4 +288,15 @@ export const featureToMutationMap = {
|
|||
},
|
||||
}),
|
||||
},
|
||||
[REPORT_TYPE_SECRET_DETECTION]: {
|
||||
mutationId: 'configureSecretDetection',
|
||||
getMutationPayload: (projectPath) => ({
|
||||
mutation: configureSecretDetectionMutation,
|
||||
variables: {
|
||||
input: {
|
||||
projectPath,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -46,8 +46,7 @@ export default {
|
|||
return button;
|
||||
},
|
||||
showManageViaMr() {
|
||||
const { available, configured, canEnableByMergeRequest } = this.feature;
|
||||
return canEnableByMergeRequest && available && !configured;
|
||||
return ManageViaMr.canRender(this.feature);
|
||||
},
|
||||
cardClasses() {
|
||||
return { 'gl-bg-gray-10': !this.available };
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
mutation configureSecretDetection($input: ConfigureSecretDetectionInput!) {
|
||||
configureSecretDetection(input: $input) {
|
||||
successPath
|
||||
errors
|
||||
}
|
||||
}
|
|
@ -5,6 +5,10 @@ import { redirectTo } from '~/lib/utils/url_utility';
|
|||
import { sprintf, s__ } from '~/locale';
|
||||
import apolloProvider from '../provider';
|
||||
|
||||
function mutationSettingsForFeatureType(type) {
|
||||
return featureToMutationMap[type];
|
||||
}
|
||||
|
||||
export default {
|
||||
apolloProvider,
|
||||
components: {
|
||||
|
@ -33,17 +37,19 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
featureSettings() {
|
||||
return featureToMutationMap[this.feature.type];
|
||||
mutationSettings() {
|
||||
return mutationSettingsForFeatureType(this.feature.type);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async mutate() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const mutation = this.featureSettings;
|
||||
const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath));
|
||||
const { errors, successPath } = data[mutation.mutationId];
|
||||
const { mutationSettings } = this;
|
||||
const { data } = await this.$apollo.mutate(
|
||||
mutationSettings.getMutationPayload(this.projectPath),
|
||||
);
|
||||
const { errors, successPath } = data[mutationSettings.mutationId];
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors[0]);
|
||||
|
@ -62,6 +68,22 @@ export default {
|
|||
}
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Returns a boolean representing whether this component can be rendered for
|
||||
* the given feature. Useful for parent components to determine whether or
|
||||
* not to render this component.
|
||||
* @param {Object} feature The feature to check.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canRender(feature) {
|
||||
const { available, configured, canEnableByMergeRequest, type } = feature;
|
||||
return (
|
||||
canEnableByMergeRequest &&
|
||||
available &&
|
||||
!configured &&
|
||||
Boolean(mutationSettingsForFeatureType(type))
|
||||
);
|
||||
},
|
||||
i18n: {
|
||||
buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
|
||||
noSuccessPathError: s__(
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
:urgency: :low
|
||||
:resource_boundary: :unknown
|
||||
:weight: 1
|
||||
:idempotent:
|
||||
:idempotent: true
|
||||
:tags: []
|
||||
- :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range
|
||||
:worker_name: AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module AuthorizedProjectUpdate
|
||||
class UserRefreshFromReplicaWorker # rubocop:disable Scalability/IdempotentWorker
|
||||
class UserRefreshFromReplicaWorker
|
||||
include ApplicationWorker
|
||||
|
||||
sidekiq_options retry: 3
|
||||
|
@ -9,31 +9,36 @@ module AuthorizedProjectUpdate
|
|||
urgency :low
|
||||
queue_namespace :authorized_project_update
|
||||
|
||||
# This job will not be deduplicated since it is marked with
|
||||
# `data_consistency :delayed` and not `idempotent!`
|
||||
# See https://gitlab.com/gitlab-org/gitlab/-/issues/325291
|
||||
idempotent!
|
||||
deduplicate :until_executing, including_scheduled: true
|
||||
|
||||
data_consistency :delayed
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find_by_id(user_id)
|
||||
return unless user
|
||||
|
||||
if Feature.enabled?(:user_refresh_from_replica_worker_uses_replica_db)
|
||||
enqueue_project_authorizations_refresh(user) if project_authorizations_needs_refresh?(user)
|
||||
use_replica_if_available do
|
||||
user = User.find_by_id(user_id)
|
||||
|
||||
if user && project_authorizations_needs_refresh?(user)
|
||||
enqueue_project_authorizations_refresh(user)
|
||||
end
|
||||
end
|
||||
else
|
||||
use_primary_database
|
||||
user = User.find_by_id(user_id)
|
||||
return unless user
|
||||
|
||||
user.refresh_authorized_projects(source: self.class.name)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def use_primary_database
|
||||
if ::Gitlab::Database::LoadBalancing.enable?
|
||||
::Gitlab::Database::LoadBalancing::Session.current.use_primary!
|
||||
end
|
||||
# We use this approach instead of specifying `data_consistency :delayed` because these jobs
|
||||
# are enqueued in large numbers, and using `data_consistency :delayed`
|
||||
# does not allow us to deduplicate these jobs.
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/325291
|
||||
def use_replica_if_available(&block)
|
||||
return yield unless ::Gitlab::Database::LoadBalancing.enable?
|
||||
|
||||
::Gitlab::Database::LoadBalancing::Session.current.use_replicas_for_read_queries(&block)
|
||||
end
|
||||
|
||||
def project_authorizations_needs_refresh?(user)
|
||||
|
|
|
@ -17923,6 +17923,9 @@ msgstr ""
|
|||
msgid "Invite a group"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invite email has already been taken"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invite group"
|
||||
msgstr ""
|
||||
|
||||
|
@ -17974,7 +17977,7 @@ msgstr ""
|
|||
msgid "InviteMembersBanner|We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge."
|
||||
msgstr ""
|
||||
|
||||
msgid "InviteMembersModal|%{linkStart}Learn more%{linkEnd} about roles."
|
||||
msgid "InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions"
|
||||
msgstr ""
|
||||
|
||||
msgid "InviteMembersModal|Access expiration date (optional)"
|
||||
|
@ -18013,7 +18016,7 @@ msgstr ""
|
|||
msgid "InviteMembersModal|Select members or type email addresses"
|
||||
msgstr ""
|
||||
|
||||
msgid "InviteMembersModal|Some of the members could not be added"
|
||||
msgid "InviteMembersModal|Something went wrong"
|
||||
msgstr ""
|
||||
|
||||
msgid "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group."
|
||||
|
|
|
@ -19,6 +19,10 @@ module QA
|
|||
element :group_select_dropdown_search_field
|
||||
end
|
||||
|
||||
base.view 'app/assets/javascripts/invite_members/components/members_token_select.vue' do
|
||||
element :members_token_select_input
|
||||
end
|
||||
|
||||
base.view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do
|
||||
element :invite_a_group_button
|
||||
end
|
||||
|
@ -42,7 +46,7 @@ module QA
|
|||
within_element(:invite_members_modal_content) do
|
||||
fill_element :access_level_dropdown, with: access_level
|
||||
|
||||
fill_in 'Select members or type email addresses', with: username
|
||||
fill_element :members_token_select_input, username
|
||||
|
||||
Support::WaitForRequests.wait_for_requests
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ module QA
|
|||
describe 'Email Notification' do
|
||||
include Support::Api
|
||||
|
||||
let(:user) do
|
||||
let!(:user) do
|
||||
Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1)
|
||||
end
|
||||
|
||||
|
|
|
@ -93,13 +93,13 @@ RSpec.describe 'Groups > Members > Manage members' do
|
|||
visit group_group_members_path(group)
|
||||
|
||||
click_on 'Invite members'
|
||||
fill_in 'Select members or type email addresses', with: '@gitlab.com'
|
||||
find('[data-testid="members-token-select-input"]').set('@gitlab.com')
|
||||
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content('No matches found')
|
||||
|
||||
fill_in 'Select members or type email addresses', with: 'undisclosed_email@gitlab.com'
|
||||
find('[data-testid="members-token-select-input"]').set('undisclosed_email@gitlab.com')
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content("Jane 'invisible' Doe")
|
||||
|
|
|
@ -1,11 +1,27 @@
|
|||
import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink, GlModal } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import {
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlDatepicker,
|
||||
GlFormGroup,
|
||||
GlSprintf,
|
||||
GlLink,
|
||||
GlModal,
|
||||
} from '@gitlab/ui';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import Api from '~/api';
|
||||
import ExperimentTracking from '~/experimentation/experiment_tracking';
|
||||
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
|
||||
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
|
||||
import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import httpStatus from '~/lib/utils/http_status';
|
||||
import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
|
||||
|
||||
let wrapper;
|
||||
let mock;
|
||||
|
||||
jest.mock('~/experimentation/experiment_tracking');
|
||||
|
||||
|
@ -26,10 +42,16 @@ const user3 = {
|
|||
username: 'one_2',
|
||||
avatar_url: '',
|
||||
};
|
||||
const user4 = {
|
||||
id: 'user-defined-token',
|
||||
name: 'email4@example.com',
|
||||
username: 'one_4',
|
||||
avatar_url: '',
|
||||
};
|
||||
const sharedGroup = { id: '981' };
|
||||
|
||||
const createComponent = (data = {}, props = {}) => {
|
||||
return shallowMount(InviteMembersModal, {
|
||||
wrapper = shallowMountExtended(InviteMembersModal, {
|
||||
propsData: {
|
||||
id,
|
||||
name,
|
||||
|
@ -51,46 +73,56 @@ const createComponent = (data = {}, props = {}) => {
|
|||
GlDropdown: true,
|
||||
GlDropdownItem: true,
|
||||
GlSprintf,
|
||||
GlFormGroup: stubComponent(GlFormGroup, {
|
||||
props: ['state', 'invalidFeedback'],
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createInviteMembersToProjectWrapper = () => {
|
||||
return createComponent({ inviteeType: 'members' }, { isProject: true });
|
||||
createComponent({ inviteeType: 'members' }, { isProject: true });
|
||||
};
|
||||
|
||||
const createInviteMembersToGroupWrapper = () => {
|
||||
return createComponent({ inviteeType: 'members' }, { isProject: false });
|
||||
createComponent({ inviteeType: 'members' }, { isProject: false });
|
||||
};
|
||||
|
||||
const createInviteGroupToProjectWrapper = () => {
|
||||
return createComponent({ inviteeType: 'group' }, { isProject: true });
|
||||
createComponent({ inviteeType: 'group' }, { isProject: true });
|
||||
};
|
||||
|
||||
const createInviteGroupToGroupWrapper = () => {
|
||||
return createComponent({ inviteeType: 'group' }, { isProject: false });
|
||||
createComponent({ inviteeType: 'group' }, { isProject: false });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
gon.api_version = 'v4';
|
||||
mock = new MockAdapter(axios);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
let wrapper;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
const findDropdown = () => wrapper.findComponent(GlDropdown);
|
||||
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
|
||||
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
|
||||
const findLink = () => wrapper.findComponent(GlLink);
|
||||
const findIntroText = () => wrapper.find({ ref: 'introText' }).text();
|
||||
const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' });
|
||||
const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' });
|
||||
const findCancelButton = () => wrapper.findByTestId('cancel-button');
|
||||
const findInviteButton = () => wrapper.findByTestId('invite-button');
|
||||
const clickInviteButton = () => findInviteButton().vm.$emit('click');
|
||||
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
|
||||
const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback');
|
||||
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
|
||||
|
||||
describe('rendering the modal', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent();
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('renders the modal with the correct title', () => {
|
||||
|
@ -132,7 +164,7 @@ describe('InviteMembersModal', () => {
|
|||
describe('when inviting to a project', () => {
|
||||
describe('when inviting members', () => {
|
||||
it('includes the correct invitee, type, and formatted name', () => {
|
||||
wrapper = createInviteMembersToProjectWrapper();
|
||||
createInviteMembersToProjectWrapper();
|
||||
|
||||
expect(findIntroText()).toBe("You're inviting members to the test name project.");
|
||||
});
|
||||
|
@ -140,7 +172,7 @@ describe('InviteMembersModal', () => {
|
|||
|
||||
describe('when sharing with a group', () => {
|
||||
it('includes the correct invitee, type, and formatted name', () => {
|
||||
wrapper = createInviteGroupToProjectWrapper();
|
||||
createInviteGroupToProjectWrapper();
|
||||
|
||||
expect(findIntroText()).toBe("You're inviting a group to the test name project.");
|
||||
});
|
||||
|
@ -150,7 +182,7 @@ describe('InviteMembersModal', () => {
|
|||
describe('when inviting to a group', () => {
|
||||
describe('when inviting members', () => {
|
||||
it('includes the correct invitee, type, and formatted name', () => {
|
||||
wrapper = createInviteMembersToGroupWrapper();
|
||||
createInviteMembersToGroupWrapper();
|
||||
|
||||
expect(findIntroText()).toBe("You're inviting members to the test name group.");
|
||||
});
|
||||
|
@ -158,7 +190,7 @@ describe('InviteMembersModal', () => {
|
|||
|
||||
describe('when sharing with a group', () => {
|
||||
it('includes the correct invitee, type, and formatted name', () => {
|
||||
wrapper = createInviteGroupToGroupWrapper();
|
||||
createInviteGroupToGroupWrapper();
|
||||
|
||||
expect(findIntroText()).toBe("You're inviting a group to the test name group.");
|
||||
});
|
||||
|
@ -167,22 +199,30 @@ describe('InviteMembersModal', () => {
|
|||
});
|
||||
|
||||
describe('submitting the invite form', () => {
|
||||
const apiErrorMessage = 'Member already exists';
|
||||
const mockMembersApi = (code, data) => {
|
||||
mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data);
|
||||
};
|
||||
const mockInvitationsApi = (code, data) => {
|
||||
mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data);
|
||||
};
|
||||
|
||||
const expectedEmailRestrictedError =
|
||||
"email 'email@example.com' does not match the allowed domains: example1.org";
|
||||
const expectedSyntaxError = 'email contains an invalid email address';
|
||||
|
||||
describe('when inviting an existing user to group by user ID', () => {
|
||||
const postData = {
|
||||
user_id: '1',
|
||||
user_id: '1,2',
|
||||
access_level: defaultAccessLevel,
|
||||
expires_at: undefined,
|
||||
invite_source: inviteSource,
|
||||
format: 'json',
|
||||
};
|
||||
|
||||
describe('when invites are sent successfully', () => {
|
||||
describe('when member is added successfully', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createInviteMembersToGroupWrapper();
|
||||
createComponent({ newUsersToInvite: [user1, user2] });
|
||||
|
||||
wrapper.setData({ newUsersToInvite: [user1] });
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
|
||||
|
@ -190,54 +230,102 @@ describe('InviteMembersModal', () => {
|
|||
clickInviteButton();
|
||||
});
|
||||
|
||||
it('calls Api addGroupMembersByUserId with the correct params', () => {
|
||||
it('calls Api addGroupMembersByUserId with the correct params', async () => {
|
||||
await waitForPromises;
|
||||
|
||||
expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData);
|
||||
});
|
||||
|
||||
it('displays the successful toastMessage', () => {
|
||||
it('displays the successful toastMessage', async () => {
|
||||
await waitForPromises;
|
||||
|
||||
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the invite received an api error message', () => {
|
||||
describe('when member is not added successfully', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user1] });
|
||||
createInviteMembersToGroupWrapper();
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest
|
||||
.spyOn(Api, 'addGroupMembersByUserId')
|
||||
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageError');
|
||||
wrapper.setData({ newUsersToInvite: [user1] });
|
||||
});
|
||||
|
||||
it('displays "Member already exists" api message for http status conflict', async () => {
|
||||
mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
|
||||
|
||||
clickInviteButton();
|
||||
});
|
||||
|
||||
it('displays the apiErrorMessage in the toastMessage', async () => {
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.vm.showToastMessageError).toHaveBeenCalledWith({
|
||||
response: { data: { message: apiErrorMessage } },
|
||||
});
|
||||
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
|
||||
expect(findMembersFormGroup().props('state')).toBe(false);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when any invite failed for any other reason', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest
|
||||
.spyOn(Api, 'addGroupMembersByUserId')
|
||||
.mockRejectedValue({ response: { data: { success: false } } });
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageError');
|
||||
it('clears the invalid state and message once the list of members to invite is cleared', async () => {
|
||||
mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
|
||||
|
||||
clickInviteButton();
|
||||
});
|
||||
|
||||
it('displays the generic error toastMessage', async () => {
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
|
||||
expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
|
||||
expect(findMembersFormGroup().props('state')).toBe(false);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
|
||||
findMembersSelect().vm.$emit('clear');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe('');
|
||||
expect(findMembersFormGroup().props('state')).not.toBe(false);
|
||||
expect(findMembersSelect().props('validationState')).not.toBe(false);
|
||||
});
|
||||
|
||||
it('displays the generic error for http server error', async () => {
|
||||
mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500');
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('displays the restricted user api message for response with bad request', async () => {
|
||||
mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError);
|
||||
});
|
||||
|
||||
it('displays the first part of the error when multiple existing users are restricted by email', async () => {
|
||||
mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(
|
||||
"root: User email 'admin@example.com' does not match the allowed domain of example2.com",
|
||||
);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays an access_level error message received for the existing user', async () => {
|
||||
mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(
|
||||
'should be greater than or equal to Owner inherited membership from group Gitlab Org',
|
||||
);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -253,7 +341,7 @@ describe('InviteMembersModal', () => {
|
|||
|
||||
describe('when invites are sent successfully', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user3] });
|
||||
createComponent({ newUsersToInvite: [user3] });
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
|
||||
|
@ -271,23 +359,84 @@ describe('InviteMembersModal', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when any invite failed for any reason', () => {
|
||||
describe('when invites are not sent successfully', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user1, user2] });
|
||||
createInviteMembersToGroupWrapper();
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest
|
||||
.spyOn(Api, 'addGroupMembersByUserId')
|
||||
.mockRejectedValue({ response: { data: { success: false } } });
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageError');
|
||||
|
||||
clickInviteButton();
|
||||
wrapper.setData({ newUsersToInvite: [user3] });
|
||||
});
|
||||
|
||||
it('displays the generic error toastMessage', async () => {
|
||||
it('displays the api error for invalid email syntax', async () => {
|
||||
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays the restricted email error when restricted email is invited', async () => {
|
||||
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays the successful toast message when email has already been invited', async () => {
|
||||
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
|
||||
expect(findMembersSelect().props('validationState')).toBe(null);
|
||||
});
|
||||
|
||||
it('displays the first error message when multiple emails return a restricted error message', async () => {
|
||||
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays the invalid syntax error for bad request', async () => {
|
||||
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when multiple emails are invited at the same time', () => {
|
||||
it('displays the invalid syntax error if one of the emails is invalid', async () => {
|
||||
createInviteMembersToGroupWrapper();
|
||||
|
||||
wrapper.setData({ newUsersToInvite: [user3, user4] });
|
||||
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID);
|
||||
|
||||
clickInviteButton();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
|
||||
expect(findMembersSelect().props('validationState')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -305,7 +454,7 @@ describe('InviteMembersModal', () => {
|
|||
|
||||
describe('when invites are sent successfully', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user1, user3] });
|
||||
createComponent({ newUsersToInvite: [user1, user3] });
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
|
||||
|
@ -350,24 +499,20 @@ describe('InviteMembersModal', () => {
|
|||
|
||||
describe('when any invite failed for any reason', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user1, user3] });
|
||||
createInviteMembersToGroupWrapper();
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
wrapper.setData({ newUsersToInvite: [user1, user3] });
|
||||
|
||||
jest
|
||||
.spyOn(Api, 'inviteGroupMembersByEmail')
|
||||
.mockRejectedValue({ response: { data: { success: false } } });
|
||||
|
||||
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageError');
|
||||
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
|
||||
mockMembersApi(httpStatus.OK, '200 OK');
|
||||
|
||||
clickInviteButton();
|
||||
});
|
||||
|
||||
it('displays the generic error toastMessage', async () => {
|
||||
it('displays the first error message', async () => {
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
|
||||
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -382,7 +527,7 @@ describe('InviteMembersModal', () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
|
||||
createComponent({ groupToBeSharedWith: sharedGroup });
|
||||
|
||||
wrapper.setData({ inviteeType: 'group' });
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
|
@ -403,7 +548,7 @@ describe('InviteMembersModal', () => {
|
|||
|
||||
describe('when sharing the group fails', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ groupToBeSharedWith: sharedGroup });
|
||||
createComponent({ groupToBeSharedWith: sharedGroup });
|
||||
|
||||
wrapper.setData({ inviteeType: 'group' });
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
|
@ -412,22 +557,20 @@ describe('InviteMembersModal', () => {
|
|||
.spyOn(Api, 'groupShareWithGroup')
|
||||
.mockRejectedValue({ response: { data: { success: false } } });
|
||||
|
||||
jest.spyOn(wrapper.vm, 'showToastMessageError');
|
||||
|
||||
clickInviteButton();
|
||||
});
|
||||
|
||||
it('displays the generic error toastMessage', async () => {
|
||||
it('displays the generic error message', async () => {
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.vm.showToastMessageError).toHaveBeenCalled();
|
||||
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracking', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({ newUsersToInvite: [user3] });
|
||||
createComponent({ newUsersToInvite: [user3] });
|
||||
|
||||
wrapper.vm.$toast = { show: jest.fn() };
|
||||
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
|
||||
|
|
|
@ -115,6 +115,21 @@ describe('MembersTokenSelect', () => {
|
|||
expect(wrapper.emitted().input[0][0]).toEqual([user1, user2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user is removed', () => {
|
||||
it('emits `clear` event', () => {
|
||||
findTokenSelector().vm.$emit('token-remove', [user1]);
|
||||
|
||||
expect(wrapper.emitted('clear')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('does not emit `clear` event when there are still tokens selected', () => {
|
||||
findTokenSelector().vm.$emit('input', [user1, user2]);
|
||||
findTokenSelector().vm.$emit('token-remove', [user1]);
|
||||
|
||||
expect(wrapper.emitted('clear')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when text input is blurred', () => {
|
||||
|
|
74
spec/frontend/invite_members/mock_data/api_responses.js
Normal file
74
spec/frontend/invite_members/mock_data/api_responses.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
const INVITATIONS_API_EMAIL_INVALID = {
|
||||
message: { error: 'email contains an invalid email address' },
|
||||
};
|
||||
|
||||
const INVITATIONS_API_ERROR_EMAIL_INVALID = {
|
||||
error: 'email contains an invalid email address',
|
||||
};
|
||||
|
||||
const INVITATIONS_API_EMAIL_RESTRICTED = {
|
||||
message: {
|
||||
'email@example.com':
|
||||
"Invite email 'email@example.com' does not match the allowed domains: example1.org",
|
||||
},
|
||||
status: 'error',
|
||||
};
|
||||
|
||||
const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = {
|
||||
message: {
|
||||
'email@example.com':
|
||||
"Invite email email 'email@example.com' does not match the allowed domains: example1.org",
|
||||
'email4@example.com':
|
||||
"Invite email email 'email4@example.com' does not match the allowed domains: example1.org",
|
||||
},
|
||||
status: 'error',
|
||||
};
|
||||
|
||||
const INVITATIONS_API_EMAIL_TAKEN = {
|
||||
message: {
|
||||
'email@example2.com': 'Invite email has already been taken',
|
||||
},
|
||||
status: 'error',
|
||||
};
|
||||
|
||||
const MEMBERS_API_MEMBER_ALREADY_EXISTS = {
|
||||
message: 'Member already exists',
|
||||
};
|
||||
|
||||
const MEMBERS_API_SINGLE_USER_RESTRICTED = {
|
||||
message: { user: ["email 'email@example.com' does not match the allowed domains: example1.org"] },
|
||||
};
|
||||
|
||||
const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = {
|
||||
message: {
|
||||
access_level: [
|
||||
'should be greater than or equal to Owner inherited membership from group Gitlab Org',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = {
|
||||
message:
|
||||
"root: User email 'admin@example.com' does not match the allowed domain of example2.com and user18: User email 'user18@example.org' does not match the allowed domain of example2.com",
|
||||
status: 'error',
|
||||
};
|
||||
|
||||
export const apiPaths = {
|
||||
GROUPS_MEMBERS: '/api/v4/groups/1/members',
|
||||
GROUPS_INVITATIONS: '/api/v4/groups/1/invitations',
|
||||
};
|
||||
|
||||
export const membersApiResponse = {
|
||||
MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS,
|
||||
SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL,
|
||||
SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED,
|
||||
MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED,
|
||||
};
|
||||
|
||||
export const invitationsApiResponse = {
|
||||
EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID,
|
||||
ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID,
|
||||
EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED,
|
||||
MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED,
|
||||
EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN,
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import {
|
||||
responseMessageFromSuccess,
|
||||
responseMessageFromError,
|
||||
} from '~/invite_members/utils/response_message_parser';
|
||||
|
||||
describe('Response message parser', () => {
|
||||
const expectedMessage = 'expected display message';
|
||||
|
||||
describe('parse message from successful response', () => {
|
||||
const exampleKeyedMsg = { 'email@example.com': expectedMessage };
|
||||
const exampleUserMsgMultiple =
|
||||
' and username1: id not found and username2: email is restricted';
|
||||
|
||||
it.each([
|
||||
[[{ data: { message: expectedMessage } }]],
|
||||
[[{ data: { message: expectedMessage + exampleUserMsgMultiple } }]],
|
||||
[[{ data: { error: expectedMessage } }]],
|
||||
[[{ data: { message: [expectedMessage] } }]],
|
||||
[[{ data: { message: exampleKeyedMsg } }]],
|
||||
])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
|
||||
expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message from error response', () => {
|
||||
it.each([
|
||||
[{ response: { data: { error: expectedMessage } } }],
|
||||
[{ response: { data: { message: { user: [expectedMessage] } } } }],
|
||||
[{ response: { data: { message: { access_level: [expectedMessage] } } } }],
|
||||
[{ response: { data: { message: { error: expectedMessage } } } }],
|
||||
[{ response: { data: { message: expectedMessage } } }],
|
||||
])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => {
|
||||
expect(responseMessageFromError(errorResponse)).toBe(expectedMessage);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
|
|||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import FeatureCard from '~/security_configuration/components/feature_card.vue';
|
||||
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
|
||||
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
|
||||
import { makeFeature } from './utils';
|
||||
|
||||
describe('FeatureCard component', () => {
|
||||
|
@ -126,21 +127,23 @@ describe('FeatureCard component', () => {
|
|||
|
||||
describe('actions', () => {
|
||||
describe.each`
|
||||
context | available | configured | configurationPath | canEnableByMergeRequest | action
|
||||
${'unavailable'} | ${false} | ${false} | ${null} | ${false} | ${null}
|
||||
${'available'} | ${true} | ${false} | ${null} | ${false} | ${'guide'}
|
||||
${'configured'} | ${true} | ${true} | ${null} | ${false} | ${'guide'}
|
||||
${'available, can enable by MR'} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'}
|
||||
${'configured, can enable by MR'} | ${true} | ${true} | ${null} | ${true} | ${'guide'}
|
||||
${'available with config path'} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'}
|
||||
${'available with config path, can enable by MR'} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'}
|
||||
${'configured with config path'} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'}
|
||||
${'configured with config path, can enable by MR'} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'}
|
||||
context | type | available | configured | configurationPath | canEnableByMergeRequest | action
|
||||
${'unavailable'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${null} | ${false} | ${null}
|
||||
${'available'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${false} | ${'guide'}
|
||||
${'configured'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${false} | ${'guide'}
|
||||
${'available, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'}
|
||||
${'available, can enable by MR, unknown type'} | ${'foo'} | ${true} | ${false} | ${null} | ${true} | ${'guide'}
|
||||
${'configured, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${true} | ${'guide'}
|
||||
${'available with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'}
|
||||
${'available with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'}
|
||||
${'configured with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'}
|
||||
${'configured with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'}
|
||||
`(
|
||||
'given $context feature',
|
||||
({ available, configured, configurationPath, canEnableByMergeRequest, action }) => {
|
||||
({ type, available, configured, configurationPath, canEnableByMergeRequest, action }) => {
|
||||
beforeEach(() => {
|
||||
feature = makeFeature({
|
||||
type,
|
||||
available,
|
||||
configured,
|
||||
configurationPath,
|
||||
|
|
|
@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises';
|
|||
import { humanize } from '~/lib/utils/text_utility';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
|
||||
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
|
||||
import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks';
|
||||
|
||||
jest.mock('~/lib/utils/url_utility');
|
||||
|
@ -169,6 +170,29 @@ describe('ManageViaMr component', () => {
|
|||
},
|
||||
);
|
||||
|
||||
describe('canRender static method', () => {
|
||||
it.each`
|
||||
context | type | available | configured | canEnableByMergeRequest | expectedValue
|
||||
${'an unconfigured feature'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${true} | ${true}
|
||||
${'a configured feature'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${true} | ${false}
|
||||
${'an unavailable feature'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${true} | ${false}
|
||||
${'a feature which cannot be enabled via MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${false} | ${false}
|
||||
${'an unknown feature'} | ${'foo'} | ${true} | ${false} | ${true} | ${false}
|
||||
`(
|
||||
'given $context returns $expectedValue',
|
||||
({ type, available, configured, canEnableByMergeRequest, expectedValue }) => {
|
||||
expect(
|
||||
ManageViaMr.canRender({
|
||||
type,
|
||||
available,
|
||||
configured,
|
||||
canEnableByMergeRequest,
|
||||
}),
|
||||
).toBe(expectedValue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('button props', () => {
|
||||
it('passes the variant and category props to the GlButton', () => {
|
||||
const variant = 'danger';
|
||||
|
|
|
@ -9,7 +9,7 @@ module Spec
|
|||
click_on 'Invite members'
|
||||
|
||||
page.within '#invite-members-modal' do
|
||||
fill_in 'Select members or type email addresses', with: name
|
||||
find('[data-testid="members-token-select-input"]').set(name)
|
||||
|
||||
wait_for_requests
|
||||
click_button name
|
||||
|
|
|
@ -12,9 +12,9 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker do
|
|||
expect(described_class.get_urgency).to eq(:low)
|
||||
end
|
||||
|
||||
it_behaves_like 'worker with data consistency',
|
||||
described_class,
|
||||
data_consistency: :delayed
|
||||
it_behaves_like 'an idempotent worker' do
|
||||
let(:job_args) { user.id }
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'checks if a project_authorization refresh is needed for the user' do
|
||||
|
@ -44,24 +44,23 @@ RSpec.describe AuthorizedProjectUpdate::UserRefreshFromReplicaWorker do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with load balancing enabled' do
|
||||
before do
|
||||
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true)
|
||||
end
|
||||
|
||||
it 'reads from the replica database' do
|
||||
expect(Gitlab::Database::LoadBalancing::Session.current).to receive(:use_replicas_for_read_queries).and_call_original
|
||||
|
||||
execute_worker
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the feature flag `user_refresh_from_replica_worker_uses_replica_db` is disabled' do
|
||||
before do
|
||||
stub_feature_flags(user_refresh_from_replica_worker_uses_replica_db: false)
|
||||
end
|
||||
|
||||
context 'when load balancing is enabled' do
|
||||
before do
|
||||
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true)
|
||||
end
|
||||
|
||||
it 'reads from the primary database' do
|
||||
expect(Gitlab::Database::LoadBalancing::Session.current)
|
||||
.to receive(:use_primary!)
|
||||
|
||||
execute_worker
|
||||
end
|
||||
end
|
||||
|
||||
it 'calls Users::RefreshAuthorizedProjectsService' do
|
||||
source = 'AuthorizedProjectUpdate::UserRefreshFromReplicaWorker'
|
||||
expect_next_instance_of(Users::RefreshAuthorizedProjectsService, user, { source: source }) do |service|
|
||||
|
|
Loading…
Reference in a new issue