Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-07-15 21:09:09 +00:00
parent d811b6d8f6
commit d1377e82d9
23 changed files with 653 additions and 168 deletions

View file

@ -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)"

View file

@ -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
>

View file

@ -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>

View file

@ -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'),
};

View file

@ -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 || '';
}

View file

@ -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,
},
},
}),
},
};

View file

@ -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 };

View file

@ -0,0 +1,6 @@
mutation configureSecretDetection($input: ConfigureSecretDetectionInput!) {
configureSecretDetection(input: $input) {
successPath
errors
}
}

View file

@ -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__(

View file

@ -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

View file

@ -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)

View file

@ -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."

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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({});

View file

@ -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', () => {

View 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,
};

View file

@ -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);
});
});
});

View file

@ -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,

View file

@ -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';

View file

@ -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

View file

@ -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|