Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-14 06:08:49 +00:00
parent 962711501f
commit ca1dcb848f
40 changed files with 810 additions and 538 deletions

View File

@ -1 +1 @@
1.59.0
1.60.0

View File

@ -12,3 +12,5 @@ export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
};
export const DESIGN_DETAIL_LAYOUT_CLASSLIST = ['design-detail-layout', 'overflow-hidden', 'm-0'];
export const MAXIMUM_FILE_UPLOAD_LIMIT = 10;

View File

@ -4,16 +4,15 @@ import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import VueDraggable from 'vuedraggable';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { getFilename, validateImageName } from '~/lib/utils/file_upload';
import { __, s__, sprintf } from '~/locale';
import { __, s__ } from '~/locale';
import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import DeleteButton from '../components/delete_button.vue';
import DesignDestroyer from '../components/design_destroyer.vue';
import Design from '../components/list/item.vue';
import UploadButton from '../components/upload/button.vue';
import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
import { MAXIMUM_FILE_UPLOAD_LIMIT, VALID_DESIGN_FILE_MIMETYPE } from '../constants';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import allDesignsMixin from '../mixins/all_designs';
@ -35,11 +34,10 @@ import {
UPLOAD_DESIGN_INVALID_FILETYPE_ERROR,
designUploadSkippedWarning,
designDeletionError,
MAXIMUM_FILE_UPLOAD_LIMIT_REACHED,
} from '../utils/error_messages';
import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
export default {
components: {
GlLoadingIcon,
@ -87,6 +85,7 @@ export default {
isDraggingDesign: false,
reorderedDesigns: null,
isReorderingInProgress: false,
uploadError: null,
};
},
computed: {
@ -159,16 +158,7 @@ export default {
if (!this.canCreateDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
createFlash({
message: sprintf(
s__(
'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
),
{
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
},
),
});
this.uploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED;
return false;
}
@ -206,7 +196,7 @@ export default {
const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
if (skippedWarningMessage) {
createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING });
this.uploadError = skippedWarningMessage;
}
// if this upload resulted in a new version being created, redirect user to the latest version
@ -229,7 +219,7 @@ export default {
},
onUploadDesignError() {
this.resetFilesToBeSaved();
createFlash({ message: UPLOAD_DESIGN_ERROR });
this.uploadError = UPLOAD_DESIGN_ERROR;
},
changeSelectedDesigns(filename) {
if (this.isDesignSelected(filename)) {
@ -260,21 +250,21 @@ export default {
},
onDesignDeleteError() {
const errorMessage = designDeletionError(this.selectedDesigns.length);
createFlash({ message: errorMessage });
this.uploadError = errorMessage;
},
onDesignDropzoneError() {
createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR });
this.uploadError = UPLOAD_DESIGN_INVALID_FILETYPE_ERROR;
},
onExistingDesignDropzoneChange(files, existingDesignFilename) {
const filesArr = Array.from(files);
if (filesArr.length > 1) {
createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE });
this.uploadError = EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE;
return;
}
if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE });
this.uploadError = EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE;
return;
}
@ -329,7 +319,7 @@ export default {
optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns),
})
.catch(() => {
createFlash({ message: MOVE_DESIGN_ERROR });
this.uploadError = MOVE_DESIGN_ERROR;
})
.finally(() => {
this.isReorderingInProgress = false;
@ -338,6 +328,9 @@ export default {
onDesignMove(designs) {
this.reorderedDesigns = designs;
},
unsetUpdateError() {
this.uploadError = null;
},
},
dragOptions: {
animation: 200,
@ -356,6 +349,15 @@ export default {
@mouseenter="toggleOnPasteListener"
@mouseleave="toggleOffPasteListener"
>
<gl-alert
v-if="uploadError"
variant="danger"
class="gl-mb-3"
data-testid="design-update-alert"
@dismiss="unsetUpdateError"
>
{{ uploadError }}
</gl-alert>
<header
v-if="showToolbar"
class="gl-display-flex gl-my-0 gl-text-gray-900"
@ -371,6 +373,7 @@ export default {
<div
v-show="hasDesigns"
class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
data-testid="design-selector-toolbar"
>
<gl-button
v-if="isLatestVersion"

View File

@ -1,4 +1,5 @@
import { __, s__, n__, sprintf } from '~/locale';
import { MAXIMUM_FILE_UPLOAD_LIMIT } from '../constants';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
'DesignManagement|Could not add a new comment. Please try again.',
@ -27,11 +28,11 @@ export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
'You can only upload one design when dropping onto an existing design.',
'Your update failed. You can only upload one design when dropping onto an existing design.',
);
export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
'You must upload a file with the same file name when dropping onto an existing design.',
'Your update failed. You must upload a file with the same file name when dropping onto an existing design.',
);
export const MOVE_DESIGN_ERROR = __(
@ -122,3 +123,12 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
return someDesignsSkippedMessage(skippedFiles);
};
export const MAXIMUM_FILE_UPLOAD_LIMIT_REACHED = sprintf(
s__(
'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
),
{
upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
},
);

View File

@ -7,12 +7,13 @@ import {
GlSprintf,
GlFormCheckboxGroup,
} from '@gitlab/ui';
import { partition, isString, uniqueId } from 'lodash';
import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import { n__ } from '~/locale';
import {
CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
@ -21,7 +22,8 @@ import {
LEARN_GITLAB,
} from '../constants';
import eventHub from '../event_hub';
import { responseMessageFromSuccess } from '../utils/response_message_parser';
import { responseFromSuccess } from '../utils/response_message_parser';
import { memberName } from '../utils/member_utils';
import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message';
import ModalConfetti from './confetti.vue';
import MembersTokenSelect from './members_token_select.vue';
@ -101,6 +103,7 @@ export default {
isLoading: false,
modalId: uniqueId('invite-members-modal-'),
newUsersToInvite: [],
invalidMembers: {},
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
source: 'unknown',
@ -125,6 +128,16 @@ export default {
inviteDisabled() {
return this.newUsersToInvite.length === 0;
},
hasInvalidMembers() {
return !isEmpty(this.invalidMembers);
},
memberErrorTitle() {
return n__(
"InviteMembersModal|The following member couldn't be invited",
"InviteMembersModal|The following %d members couldn't be invited",
Object.keys(this.invalidMembers).length,
);
},
tasksToBeDoneEnabled() {
return (
(getParameterValues('open_modal')[0] === 'invite_members_for_task' ||
@ -218,7 +231,7 @@ export default {
},
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.invalidFeedbackMessage = '';
this.clearValidation();
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
@ -242,12 +255,10 @@ export default {
...userId,
})
.then((response) => {
const message = responseMessageFromSuccess(response);
const { error, message } = responseFromSuccess(response);
if (message) {
this.showInvalidFeedbackMessage({
response: { data: { message } },
});
if (error) {
this.showMemberErrors(message);
} else {
this.showSuccessMessage();
}
@ -257,6 +268,13 @@ export default {
this.isLoading = false;
});
},
showMemberErrors(message) {
this.invalidMembers = message;
},
tokenName(username) {
// initial token creation hits this and nothing is found... so safe navigation
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
trackinviteMembersForTask() {
const label = 'selected_tasks_to_be_done';
const property = this.selectedTasksToBeDone.join(',');
@ -264,8 +282,8 @@ export default {
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
resetFields() {
this.clearValidation();
this.isLoading = false;
this.invalidFeedbackMessage = '';
this.newUsersToInvite = [];
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
@ -287,6 +305,11 @@ export default {
},
clearValidation() {
this.invalidFeedbackMessage = '';
this.invalidMembers = {};
},
removeToken(token) {
delete this.invalidMembers[memberName(token)];
this.invalidMembers = { ...this.invalidMembers };
},
},
labels: MEMBER_MODAL_LABELS,
@ -324,23 +347,40 @@ export default {
<modal-confetti v-if="isCelebration" />
</template>
<template #user-limit-notification>
<template #alert>
<gl-alert
v-if="hasInvalidMembers"
variant="danger"
:dismissible="false"
:title="memberErrorTitle"
data-testid="alert-member-error"
>
{{ $options.labels.memberErrorListText }}
<ul class="gl-pl-5">
<li v-for="(error, member) in invalidMembers" :key="member">
<strong>{{ tokenName(member) }}:</strong> {{ error }}
</li>
</ul>
</gl-alert>
<user-limit-notification
v-else
:close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
/>
</template>
<template #select="{ validationState, labelId }">
<template #select="{ exceptionState, labelId }">
<members-token-select
v-model="newUsersToInvite"
class="gl-mb-2"
:validation-state="validationState"
:exception-state="exceptionState"
:aria-labelledby="labelId"
:users-filter="usersFilter"
:filter-id="filterId"
:invalid-members="invalidMembers"
@clear="clearValidation"
@token-remove="removeToken"
/>
</template>
<template #form-after>

View File

@ -159,7 +159,7 @@ export default {
introText() {
return sprintf(this.labelIntroText, { name: this.name });
},
validationState() {
exceptionState() {
return this.invalidFeedbackMessage ? false : null;
},
selectLabelId() {
@ -306,11 +306,11 @@ export default {
<slot name="intro-text-after"></slot>
</div>
<slot name="user-limit-notification"></slot>
<slot name="alert"></slot>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
:state="exceptionState"
data-testid="members-form-group"
>
<template #description>
@ -320,7 +320,7 @@ export default {
<label :id="selectLabelId" :class="selectLabelClass">{{ labelSearchField }}</label>
<gl-form-input v-if="reachedLimit" data-testid="disabled-input" disabled />
<slot v-else name="select" v-bind="{ validationState, labelId: selectLabelId }"></slot>
<slot v-else name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot>
</gl-form-group>
<template v-if="!reachedLimit">

View File

@ -3,6 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@
import { debounce } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
import { memberName } from '../utils/member_utils';
import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
export default {
@ -23,7 +24,7 @@ export default {
type: String,
required: true,
},
validationState: {
exceptionState: {
type: Boolean,
required: false,
default: false,
@ -38,6 +39,10 @@ export default {
required: false,
default: null,
},
invalidMembers: {
type: Object,
required: true,
},
},
data() {
return {
@ -109,13 +114,18 @@ export default {
this.hasBeenFocused = true;
},
handleTokenRemove() {
handleTokenRemove(value) {
if (this.selectedTokens.length) {
this.$emit('token-remove', value);
return;
}
this.$emit('clear');
},
hasError(token) {
return Object.keys(this.invalidMembers).includes(memberName(token));
},
},
defaultQueryOptions: { without_project_bots: true, active: true },
i18n: {
@ -127,7 +137,7 @@ export default {
<template>
<gl-token-selector
v-model="selectedTokens"
:state="validationState"
:state="exceptionState"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="emailIsValid"
@ -145,8 +155,19 @@ export default {
@token-remove="handleTokenRemove"
>
<template #token-content="{ token }">
<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" />
<gl-icon
v-if="hasError(token)"
name="error"
:size="16"
class="gl-mr-2"
:data-testid="`error-icon-${token.id}`"
/>
<gl-avatar
v-else-if="token.avatar_url"
:src="token.avatar_url"
:size="16"
data-testid="token-avatar"
/>
{{ token.name }}
</template>

View File

@ -74,6 +74,9 @@ export const INVITE_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Manage member
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const CANCEL_BUTTON_TEXT_DISABLED = s__('InviteMembersModal|Explore paid plans');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
export const MEMBER_ERROR_LIST_TEXT = s__(
'InviteMembersModal|Review the invite errors and try again:',
);
export const MEMBER_MODAL_LABELS = {
modal: {
@ -109,6 +112,7 @@ export const MEMBER_MODAL_LABELS = {
title: MEMBERS_TASKS_PROJECTS_TITLE,
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
memberErrorListText: MEMBER_ERROR_LIST_TEXT,
};
export const GROUP_MODAL_LABELS = {

View File

@ -0,0 +1,4 @@
export function memberName(member) {
// user defined tokens(invites by email) will have email in `name` and will not contain `username`
return member.username || member.name;
}

View File

@ -1,15 +1,4 @@
import { isString } from 'lodash';
function responseKeyedMessageParsed(keyedMessage) {
try {
const keys = Object.keys(keyedMessage);
const msg = keyedMessage[keys[0]];
return msg;
} catch {
return '';
}
}
import { isString, isArray } from 'lodash';
export function responseMessageFromError(response) {
if (!response?.response?.data) {
@ -23,9 +12,9 @@ export function responseMessageFromError(response) {
return data.error || data.message?.error || data.message || '';
}
export function responseMessageFromSuccess(response) {
export function responseFromSuccess(response) {
if (!response?.data) {
return '';
return { error: false };
}
const { data } = response;
@ -34,11 +23,19 @@ export function responseMessageFromSuccess(response) {
const { message } = data;
if (isString(message)) {
return message;
return { message, error: true };
}
return responseKeyedMessageParsed(message);
if (isArray(message)) {
return { message: message[0], error: true };
}
// we assume object now with our keyed format
return { message: { ...message }, error: true };
}
return data.error || '';
if (data.error) {
return { message: data.error, error: true };
}
return { error: false };
}

View File

@ -1,6 +1,6 @@
<script>
import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui';
import createFlash from '~/flash';
import { GlSprintf, GlModal } from '@gitlab/ui';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
@ -10,9 +10,7 @@ import eventHub from '../event_hub';
export default {
components: {
GlModal,
},
directives: {
SafeHtml,
GlSprintf,
},
props: {
issueCount: {
@ -38,20 +36,10 @@ export default {
},
computed: {
text() {
const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', {
milestoneTitle: this.milestoneTitle,
});
if (this.issueCount === 0 && this.mergeRequestCount === 0) {
return sprintf(
s__(`Milestones|
return s__(`Milestones|
Youre about to permanently delete the milestone %{milestoneTitle}.
This milestone is not currently used in any issues or merge requests.`),
{
milestoneTitle,
},
false,
);
This milestone is not currently used in any issues or merge requests.`);
}
return sprintf(
@ -59,7 +47,6 @@ This milestone is not currently used in any issues or merge requests.`),
Youre about to permanently delete the milestone %{milestoneTitle} and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}.
Once deleted, it cannot be undone or recovered.`),
{
milestoneTitle,
issuesWithCount: n__('%d issue', '%d issues', this.issueCount),
mergeRequestsWithCount: n__(
'%d merge request',
@ -98,13 +85,13 @@ Once deleted, it cannot be undone or recovered.`),
});
if (error.response && error.response.status === 404) {
createFlash({
createAlert({
message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), {
milestoneTitle: this.milestoneTitle,
}),
});
} else {
createFlash({
createAlert({
message: sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), {
milestoneTitle: this.milestoneTitle,
}),
@ -132,6 +119,10 @@ Once deleted, it cannot be undone or recovered.`),
:action-cancel="$options.cancelProps"
@primary="onSubmit"
>
<p v-safe-html="text"></p>
<gl-sprintf :message="text">
<template #milestoneTitle>
<strong>{{ milestoneTitle }}</strong>
</template>
</gl-sprintf>
</gl-modal>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { GlAlert, GlButton, GlSkeletonLoader } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
i18n,
@ -20,6 +20,7 @@ export default {
i18n,
components: {
GlAlert,
GlButton,
GlSkeletonLoader,
WorkItemAssignees,
WorkItemActions,
@ -30,6 +31,11 @@ export default {
},
mixins: [glFeatureFlagMixin()],
props: {
isModal: {
type: Boolean,
required: false,
default: false,
},
workItemId: {
type: String,
required: false,
@ -113,24 +119,29 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
<div class="gl-font-weight-bold gl-text-secondary gl-mb-2">{{ workItemType }}</div>
<div class="gl-display-flex gl-align-items-start">
<work-item-title
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
:work-item-parent-id="workItemParentId"
class="gl-mr-5"
@error="error = $event"
/>
<div class="gl-display-flex gl-align-items-center">
<span class="gl-font-weight-bold gl-text-secondary gl-mr-auto">{{ workItemType }}</span>
<work-item-actions
:work-item-id="workItem.id"
:can-delete="canDelete"
class="gl-mt-4"
@deleteWorkItem="$emit('deleteWorkItem')"
@error="error = $event"
/>
<gl-button
v-if="isModal"
category="tertiary"
icon="close"
:aria-label="__('Close')"
@click="$emit('close')"
/>
</div>
<work-item-title
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
:work-item-parent-id="workItemParentId"
@error="error = $event"
/>
<work-item-state
:work-item="workItem"
:work-item-parent-id="workItemParentId"

View File

@ -87,6 +87,9 @@ export default {
this.error = '';
this.$emit('close');
},
hide() {
this.$refs.modal.hide();
},
setErrorMessage(message) {
this.error = message;
},
@ -111,9 +114,11 @@ export default {
</gl-alert>
<work-item-detail
is-modal
:work-item-parent-id="issueGid"
:work-item-id="workItemId"
class="gl-p-5 gl-mt-n3"
@close="hide"
@deleteWorkItem="deleteWorkItem"
/>
</gl-modal>

View File

@ -179,8 +179,7 @@ The header is created if the returned `errors` object is empty.
FLAG:
On self-managed GitLab, by default the UI for this feature is not available. To make it available per group, ask an administrator to
[enable the feature flag](../administration/feature_flags.md) named `custom_headers_streaming_audit_events_ui`. On GitLab.com, the UI for this feature is
not available. The UI for this feature is not ready for production use. Custom header values are not saved by the GitLab UI. To track progress on saving
custom header values in the GitLab UI, [see the relevant issue](https://gitlab.com/gitlab-org/gitlab/-/issues/361631).
not available. The UI for this feature is not ready for production use.
Users with at least the Owner role for a group can add event streaming destinations and custom HTTP headers for it:

View File

@ -373,6 +373,34 @@ separate Rails process to debug the issue:
1. In a new window, run `top`. It should show this Ruby process using 100% CPU. Write down the PID.
1. Follow step 2 from the previous section on using GDB.
### GitLab: API is not accessible
This often occurs when GitLab Shell attempts to request authorization via the
[internal API](../../development/internal_api/index.md) (for example, `http://localhost:8080/api/v4/internal/allowed`), and
something in the check fails. There are many reasons why this may happen:
1. Timeout connecting to a database (for example, PostgreSQL or Redis)
1. Error in Git hooks or push rules
1. Error accessing the repository (for example, stale NFS handles)
To diagnose this problem, try to reproduce the problem and then see if there
is a Puma worker that is spinning via `top`. Try to use the `gdb`
techniques above. In addition, using `strace` may help isolate issues:
```shell
strace -ttTfyyy -s 1024 -p <PID of puma worker> -o /tmp/puma.txt
```
If you cannot isolate which Puma worker is the issue, try to run `strace`
on all the Puma workers to see where the
[`/internal/allowed`](../../development/internal_api/index.md) endpoint gets stuck:
```shell
ps auwx | grep puma | awk '{ print " -p " $2}' | xargs strace -ttTfyyy -s 1024 -o /tmp/puma.txt
```
The output in `/tmp/puma.txt` may help diagnose the root cause.
## Related topics
- [Use a dedicated metrics server to export web metrics](../monitoring/prometheus/puma_exporter.md)

View File

@ -110,39 +110,6 @@ in Omnibus, run as root:
/opt/gitlab/embedded/bin/ruby /opt/gitlab/embedded/bin/rbtrace
```
## Common Problems
Many of the tips to diagnose issues below apply to many different situations. We use one
concrete example to illustrate what you can do to learn what is going wrong.
### GitLab: API is not accessible
This often occurs when GitLab Shell attempts to request authorization via the
[internal API](../../development/internal_api/index.md) (for example, `http://localhost:8080/api/v4/internal/allowed`), and
something in the check fails. There are many reasons why this may happen:
1. Timeout connecting to a database (for example, PostgreSQL or Redis)
1. Error in Git hooks or push rules
1. Error accessing the repository (for example, stale NFS handles)
To diagnose this problem, try to reproduce the problem and then see if there
is a Unicorn worker that is spinning via `top`. Try to use the `gdb`
techniques above. In addition, using `strace` may help isolate issues:
```shell
strace -ttTfyyy -s 1024 -p <PID of puma worker> -o /tmp/puma.txt
```
If you cannot isolate which Unicorn worker is the issue, try to run `strace`
on all the Unicorn workers to see where the
[`/internal/allowed`](../../development/internal_api/index.md) endpoint gets stuck:
```shell
ps auwx | grep puma | awk '{ print " -p " $2}' | xargs strace -ttTfyyy -s 1024 -o /tmp/puma.txt
```
The output in `/tmp/puma.txt` may help diagnose the root cause.
## More information
- [Debugging Stuck Ruby Processes](https://newrelic.com/blog/best-practices/debugging-stuck-ruby-processes-what-to-do-before-you-kill-9)

View File

@ -443,6 +443,43 @@ of the Code Review Comments page on the Go wiki for more details.
Most editors/IDEs allow you to run commands before/after saving a file, you can set it
up to run `goimports -local gitlab.com/gitlab-org` so that it's applied to every file when saving.
### Initializing slices
If initializing a slice, provide a capacity where possible to avoid extra
allocations.
<table>
<tr><th>:white_check_mark: Do</th><th>:x: Don't</th></tr>
<tr>
<td>
```golang
s2 := make([]string, 0, size)
for _, val := range s1 {
s2 = append(s2, val)
}
```
</td>
<td>
```golang
var s2 []string
for _, val := range s1 {
s2 = append(s2, val)
}
```
</td>
</tr>
</table>
If no capacity is passed to `make` when creating a new slice, `append`
will continuously resize the slice's backing array if it cannot hold
the values. Providing the capacity ensures that allocations are kept
to a minimum. It is recommended that the [`prealloc`](https://github.com/alexkohler/prealloc)
golanci-lint rule automatically check for this.
### Analyzer Tests
The conventional Secure [analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/) has a [`convert` function](https://gitlab.com/gitlab-org/security-products/analyzers/command/-/blob/main/convert.go#L15-17) that converts SAST/DAST scanner reports into [GitLab Security Reports](https://gitlab.com/gitlab-org/security-products/security-report-schemas). When writing tests for the `convert` function, we should make use of [test fixtures](https://dave.cheney.net/2016/05/10/test-fixtures-in-go) using a `testdata` directory at the root of the analyzer's repository. The `testdata` directory should contain two subdirectories: `expect` and `reports`. The `reports` directory should contain sample SAST/DAST scanner reports which are passed into the `convert` function during the test setup. The `expect` directory should contain the expected GitLab Security Report that the `convert` returns. See Secret Detection for an [example](https://gitlab.com/gitlab-org/security-products/analyzers/secrets/-/blob/160424589ef1eed7b91b59484e019095bc7233bd/convert_test.go#L13-66).

View File

@ -21398,6 +21398,9 @@ msgstr ""
msgid "InviteMembersModal|Members were successfully added"
msgstr ""
msgid "InviteMembersModal|Review the invite errors and try again:"
msgstr ""
msgid "InviteMembersModal|Search for a group to invite"
msgstr ""
@ -21413,6 +21416,11 @@ msgstr ""
msgid "InviteMembersModal|Something went wrong"
msgstr ""
msgid "InviteMembersModal|The following member couldn't be invited"
msgid_plural "InviteMembersModal|The following %d members couldn't be invited"
msgstr[0] ""
msgstr[1] ""
msgid "InviteMembersModal|This feature is disabled until this group has space for more members."
msgstr ""
@ -44202,9 +44210,6 @@ msgstr ""
msgid "You can only transfer the project to namespaces you manage."
msgstr ""
msgid "You can only upload one design when dropping onto an existing design."
msgstr ""
msgid "You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}"
msgstr ""
@ -44480,9 +44485,6 @@ msgstr ""
msgid "You must solve the CAPTCHA in order to submit"
msgstr ""
msgid "You must upload a file with the same file name when dropping onto an existing design."
msgstr ""
msgid "You need a different license to enable FileLocks feature"
msgstr ""
@ -44940,6 +44942,12 @@ msgid_plural "Your subscription will expire in %{remaining_days} days."
msgstr[0] ""
msgstr[1] ""
msgid "Your update failed. You can only upload one design when dropping onto an existing design."
msgstr ""
msgid "Your update failed. You must upload a file with the same file name when dropping onto an existing design."
msgstr ""
msgid "Your username is %{username}."
msgstr ""

View File

@ -26,6 +26,6 @@ RSpec.describe 'Projects > Files > User wants to add a Dockerfile file', :js do
wait_for_requests
expect(page).to have_css('.dockerfile-selector .dropdown-toggle-text', text: 'Apply a template')
expect(editor_get_value).to have_content('COPY ./ /usr/local/apache2/htdocs/')
expect(find('.monaco-editor')).to have_content('COPY ./ /usr/local/apache2/htdocs/')
end
end

View File

@ -26,7 +26,7 @@ RSpec.describe 'Projects > Files > User wants to add a .gitignore file', :js do
wait_for_requests
expect(page).to have_css('.gitignore-selector .dropdown-toggle-text', text: 'Apply a template')
expect(editor_get_value).to have_content('/.bundle')
expect(editor_get_value).to have_content('config/initializers/secret_token.rb')
expect(find('.monaco-editor')).to have_content('/.bundle')
expect(find('.monaco-editor')).to have_content('config/initializers/secret_token.rb')
end
end

View File

@ -30,8 +30,8 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file', :js
wait_for_requests
expect(page).to have_css('.gitlab-ci-yml-selector .dropdown-toggle-text', text: 'Apply a template')
expect(editor_get_value).to have_content('This file is a template, and might need editing before it works on your project')
expect(editor_get_value).to have_content('jekyll build -d test')
expect(find('.monaco-editor')).to have_content('This file is a template, and might need editing before it works on your project')
expect(find('.monaco-editor')).to have_content('jekyll build -d test')
end
context 'when template param is provided' do
@ -41,8 +41,8 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file', :js
wait_for_requests
expect(page).to have_css('.gitlab-ci-yml-selector .dropdown-toggle-text', text: 'Apply a template')
expect(editor_get_value).to have_content('This file is a template, and might need editing before it works on your project')
expect(editor_get_value).to have_content('jekyll build -d test')
expect(find('.monaco-editor')).to have_content('This file is a template, and might need editing before it works on your project')
expect(find('.monaco-editor')).to have_content('jekyll build -d test')
end
end
@ -53,7 +53,7 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file', :js
wait_for_requests
expect(page).to have_css('.gitlab-ci-yml-selector .dropdown-toggle-text', text: 'Apply a template')
expect(editor_get_value).to have_content('')
expect(find('.monaco-editor')).to have_content('')
end
end
@ -64,7 +64,7 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file', :js
it 'leaves the editor empty' do
wait_for_requests
expect(editor_get_value).to have_content('')
expect(find('.monaco-editor')).to have_content('')
end
end
end

View File

@ -22,8 +22,10 @@ RSpec.describe 'Projects > Files > Project owner sees a link to create a license
select_template('MIT License')
expect(ide_editor_value).to have_content('MIT License')
expect(ide_editor_value).to have_content("Copyright (c) #{Time.zone.now.year} #{project.namespace.human_name}")
file_content = "Copyright (c) #{Time.zone.now.year} #{project.namespace.human_name}"
expect(find('.monaco-editor')).to have_content('MIT License')
expect(find('.monaco-editor')).to have_content(file_content)
ide_commit
@ -33,7 +35,7 @@ RSpec.describe 'Projects > Files > Project owner sees a link to create a license
license_file = project.repository.blob_at('master', 'LICENSE').data
expect(license_file).to have_content('MIT License')
expect(license_file).to have_content("Copyright (c) #{Time.zone.now.year} #{project.namespace.human_name}")
expect(license_file).to have_content(file_content)
end
def select_template(template)

View File

@ -7,6 +7,8 @@ exports[`Design management index page designs renders error 1`] = `
>
<!---->
<!---->
<div
class="gl-mt-6"
>
@ -39,6 +41,8 @@ exports[`Design management index page designs renders loading icon 1`] = `
>
<!---->
<!---->
<div
class="gl-mt-6"
>

View File

@ -1,5 +1,4 @@
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo, { ApolloMutation } from 'vue-apollo';
@ -9,6 +8,7 @@ import VueDraggable from 'vuedraggable';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import DeleteButton from '~/design_management/components/delete_button.vue';
@ -23,6 +23,7 @@ import * as utils from '~/design_management/utils/design_management_utils';
import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
UPLOAD_DESIGN_ERROR,
} from '~/design_management/utils/error_messages';
import {
DESIGN_TRACKING_PAGE_NAME,
@ -101,20 +102,20 @@ describe('Design management index page', () => {
let moveDesignHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('[data-testid="select-all-designs-button"');
const findToolbar = () => wrapper.find('.qa-selector-toolbar');
const findDesignCollectionIsCopying = () =>
wrapper.find('[data-testid="design-collection-is-copying"');
const findDeleteButton = () => wrapper.find(DeleteButton);
const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
const findSelectAllButton = () => wrapper.findByTestId('select-all-designs-button');
const findToolbar = () => wrapper.findByTestId('design-selector-toolbar');
const findDesignCollectionIsCopying = () => wrapper.findByTestId('design-collection-is-copying');
const findDeleteButton = () => wrapper.findComponent(DeleteButton);
const findDropzone = () => wrapper.findAllComponents(DesignDropzone).at(0);
const dropzoneClasses = () => findDropzone().classes();
const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]');
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
const findDropzoneWrapper = () => wrapper.findByTestId('design-dropzone-wrapper');
const findFirstDropzoneWithDesign = () => wrapper.findAllComponents(DesignDropzone).at(1);
const findDesignsWrapper = () => wrapper.findByTestId('designs-root');
const findDesigns = () => wrapper.findAll(Design);
const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs;
const findDesignUploadButton = () => wrapper.find('[data-testid="design-upload-button"]');
const findDesignToolbarWrapper = () => wrapper.find('[data-testid="design-toolbar-wrapper"]');
const findDesignUploadButton = () => wrapper.findByTestId('design-upload-button');
const findDesignToolbarWrapper = () => wrapper.findByTestId('design-toolbar-wrapper');
const findDesignUpdateAlert = () => wrapper.findByTestId('design-update-alert');
async function moveDesigns(localWrapper) {
await waitForPromises();
@ -149,7 +150,7 @@ describe('Design management index page', () => {
mutate,
};
wrapper = shallowMount(Index, {
wrapper = shallowMountExtended(Index, {
data() {
return {
allVersions,
@ -185,7 +186,7 @@ describe('Design management index page', () => {
];
fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true });
wrapper = shallowMount(Index, {
wrapper = shallowMountExtended(Index, {
apolloProvider: fakeApollo,
router,
stubs: { VueDraggable },
@ -412,7 +413,8 @@ describe('Design management index page', () => {
await nextTick();
expect(wrapper.vm.filesToBeSaved).toEqual([]);
expect(wrapper.vm.isSaving).toBeFalsy();
expect(createFlash).toHaveBeenCalled();
expect(findDesignUpdateAlert().exists()).toBe(true);
expect(findDesignUpdateAlert().text()).toBe(UPLOAD_DESIGN_ERROR);
});
it('does not call mutation if createDesign is false', () => {
@ -431,19 +433,23 @@ describe('Design management index page', () => {
wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0]));
expect(createFlash).not.toHaveBeenCalled();
expect(findDesignUpdateAlert().exists()).toBe(false);
});
it('warns when too many files are uploaded', () => {
it('warns when too many files are uploaded', async () => {
createComponent();
wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0]));
await nextTick();
expect(createFlash).toHaveBeenCalled();
expect(findDesignUpdateAlert().exists()).toBe(true);
expect(findDesignUpdateAlert().text()).toBe(
'The maximum number of designs allowed to be uploaded is 10. Please try again.',
);
});
});
it('flashes warning if designs are skipped', async () => {
it('displays warning if designs are skipped', async () => {
createComponent({
mockMutate: () =>
Promise.resolve({
@ -458,11 +464,8 @@ describe('Design management index page', () => {
]);
await uploadDesign;
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message: 'Upload skipped. test.jpg did not change.',
types: 'warning',
});
expect(findDesignUpdateAlert().exists()).toBe(true);
expect(findDesignUpdateAlert().text()).toBe('Upload skipped. test.jpg did not change.');
});
describe('dragging onto an existing design', () => {
@ -495,13 +498,17 @@ describe('Design management index page', () => {
description | eventPayload | message
${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE}
${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE}
`('calls createFlash when upload has $description', ({ eventPayload, message }) => {
const designDropzone = findFirstDropzoneWithDesign();
designDropzone.vm.$emit('change', eventPayload);
`(
'displays GlAlert component when upload has $description',
async ({ eventPayload, message }) => {
expect(findDesignUpdateAlert().exists()).toBe(false);
const designDropzone = findFirstDropzoneWithDesign();
await designDropzone.vm.$emit('change', eventPayload);
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({ message });
});
expect(findDesignUpdateAlert().exists()).toBe(true);
expect(findDesignUpdateAlert().text()).toBe(message);
},
);
});
describe('tracking', () => {
@ -804,7 +811,7 @@ describe('Design management index page', () => {
expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' });
});
it('displays flash if mutation had a non-recoverable error', async () => {
it('displays alert if mutation had a non-recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockRejectedValue('Error'),
});
@ -812,9 +819,10 @@ describe('Design management index page', () => {
await moveDesigns(wrapper);
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: 'Something went wrong when reordering designs. Please try again',
});
expect(findDesignUpdateAlert().exists()).toBe(true);
expect(findDesignUpdateAlert().text()).toBe(
'Something went wrong when reordering designs. Please try again',
);
});
});
});

View File

@ -35,6 +35,7 @@ import {
user2,
user3,
user4,
user5,
GlEmoji,
} from '../mock_data/member_modal';
@ -93,6 +94,11 @@ describe('InviteMembersModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error');
const findMemberErrorMessage = (element) =>
`${Object.keys(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[element]}: ${
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[element]
}`;
const emitEventFromModal = (eventName) => () =>
findModal().vm.$emit(eventName, { preventDefault: jest.fn() });
const clickInviteButton = emitEventFromModal('primary');
@ -123,6 +129,10 @@ describe('InviteMembersModal', () => {
findBase().vm.$emit('access-level', val);
await nextTick();
};
const removeMembersToken = async (val) => {
findMembersSelect().vm.$emit('token-remove', val);
await nextTick();
};
describe('rendering the tasks to be done', () => {
const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => {
@ -431,17 +441,20 @@ describe('InviteMembersModal', () => {
});
it('clears the error when the list of members to invite is cleared', async () => {
expect(membersFormGroupInvalidFeedback()).toBe(
expect(findMemberErrorAlert().exists()).toBe(true);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
findMembersSelect().vm.$emit('clear');
await nextTick();
expect(findMemberErrorAlert().exists()).toBe(false);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('validationState')).not.toBe(false);
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('clears the error when the cancel button is clicked', async () => {
@ -450,7 +463,7 @@ describe('InviteMembersModal', () => {
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('validationState')).not.toBe(false);
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('clears the error when the modal is hidden', async () => {
@ -458,33 +471,12 @@ describe('InviteMembersModal', () => {
await nextTick();
expect(findMemberErrorAlert().exists()).toBe(false);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('validationState')).not.toBe(false);
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
});
it('clears the invalid state and message once the list of members to invite is cleared', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(
Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
findMembersSelect().vm.$emit('clear');
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('validationState')).toBe(null);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('displays the generic error for http server error', async () => {
mockInvitationsApi(
httpStatus.INTERNAL_SERVER_ERROR,
@ -496,6 +488,7 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
expect(findMembersSelect().props('exceptionState')).toBe(false);
});
it('displays the restricted user api message for response with bad request', async () => {
@ -505,20 +498,31 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError);
expect(findMemberErrorAlert().exists()).toBe(true);
expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('displays the first part of the error when multiple existing users are restricted by email', async () => {
it('displays all errors when there are multiple existing users that are restricted by email', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
expect(findMemberErrorAlert().exists()).toBe(true);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
});
});
@ -573,10 +577,30 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findMembersSelect().props('exceptionState')).toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('clears the error when the modal is hidden', async () => {
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('exceptionState')).toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
findModal().vm.$emit('hidden');
await nextTick();
expect(findMemberErrorAlert().exists()).toBe(false);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('displays the restricted email error when restricted email is invited', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
@ -584,20 +608,32 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findMemberErrorAlert().exists()).toBe(true);
expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('displays the first error message when multiple emails return a restricted error message', async () => {
it('displays all errors when there are multiple emails that return a restricted error message', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findMemberErrorAlert().exists()).toBe(true);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
);
expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
);
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('displays the invalid syntax error for bad request', async () => {
@ -608,7 +644,7 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findMembersSelect().props('exceptionState')).toBe(false);
});
});
@ -617,14 +653,51 @@ describe('InviteMembersModal', () => {
createInviteMembersToGroupWrapper();
await triggerMembersTokenSelect([user3, user4]);
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID);
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
expect(findMembersSelect().props('exceptionState')).toBe(false);
});
it('displays errors for multiple and allows clearing', async () => {
createInviteMembersToGroupWrapper();
await triggerMembersTokenSelect([user3, user4, user5]);
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
await waitForPromises();
expect(findMemberErrorAlert().exists()).toBe(true);
expect(findMemberErrorAlert().props('title')).toContain(
"The following 3 members couldn't be invited",
);
expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(0));
expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(1));
expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(2));
await removeMembersToken(user3);
expect(findMemberErrorAlert().props('title')).toContain(
"The following 2 members couldn't be invited",
);
expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(0));
await removeMembersToken(user4);
expect(findMemberErrorAlert().props('title')).toContain(
"The following member couldn't be invited",
);
expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(1));
await removeMembersToken(user5);
expect(findMemberErrorAlert().exists()).toBe(false);
});
});
});
@ -675,24 +748,6 @@ describe('InviteMembersModal', () => {
});
});
});
describe('when any invite failed for any reason', () => {
beforeEach(async () => {
createInviteMembersToGroupWrapper();
await triggerMembersTokenSelect([user1, user3]);
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
clickInviteButton();
});
it('displays the first error message', async () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
});
});
});
describe('tracking', () => {

View File

@ -254,7 +254,7 @@ describe('InviteModalBase', () => {
expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true);
});
it('with invalidFeedbackMessage, set members form group validation state', () => {
it('with invalidFeedbackMessage, set members form group exception state', () => {
createComponent({
invalidFeedbackMessage: 'invalid message!',
});

View File

@ -16,6 +16,7 @@ const createComponent = (props) => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
invalidMembers: {},
placeholder,
...props,
},
@ -124,12 +125,14 @@ describe('MembersTokenSelect', () => {
findTokenSelector().vm.$emit('token-remove', [user1]);
expect(wrapper.emitted('clear')).toEqual([[]]);
expect(wrapper.emitted('token-remove')).toBeUndefined();
});
it('does not emit `clear` event when there are still tokens selected', () => {
it('emits `token-remove` event with the token when there are still tokens selected', () => {
findTokenSelector().vm.$emit('input', [user1, user2]);
findTokenSelector().vm.$emit('token-remove', [user1]);
expect(wrapper.emitted('token-remove')).toEqual([[[user1]]]);
expect(wrapper.emitted('clear')).toBeUndefined();
});
});

View File

@ -26,13 +26,17 @@ export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '
export const user3 = {
id: 'user-defined-token',
name: 'email@example.com',
username: 'one_2',
avatar_url: '',
};
export const user4 = {
id: 'user-defined-token',
id: 'user-defined-token2',
name: 'email4@example.com',
username: 'one_4',
avatar_url: '',
};
export const user5 = {
id: '3',
username: 'root',
name: 'root',
avatar_url: '',
};

View File

@ -0,0 +1,12 @@
import { memberName } from '~/invite_members/utils/member_utils';
describe('Member Name', () => {
it.each([
[{ username: '_username_', name: '_name_' }, '_username_'],
[{ username: '_username_' }, '_username_'],
[{ name: '_name_' }, '_name_'],
[{}, undefined],
])(`returns name from supplied member token: %j`, (member, result) => {
expect(memberName(member)).toBe(result);
});
});

View File

@ -1,5 +1,5 @@
import {
responseMessageFromSuccess,
responseFromSuccess,
responseMessageFromError,
} from '~/invite_members/utils/response_message_parser';
import { invitationsApiResponse } from '../mock_data/api_responses';
@ -11,12 +11,12 @@ describe('Response message parser', () => {
const exampleKeyedMsg = { 'email@example.com': expectedMessage };
it.each([
[{ data: { message: expectedMessage } }],
[{ data: { error: expectedMessage } }],
[{ data: { message: [expectedMessage] } }],
[{ data: { message: exampleKeyedMsg } }],
])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
[{ data: { message: expectedMessage } }, { error: true, message: expectedMessage }],
[{ data: { error: expectedMessage } }, { error: true, message: expectedMessage }],
[{ data: { message: [expectedMessage] } }, { error: true, message: expectedMessage }],
[{ data: { message: exampleKeyedMsg } }, { error: true, message: { ...exampleKeyedMsg } }],
])(`returns "${expectedMessage}" from success response: %j`, (successResponse, result) => {
expect(responseFromSuccess(successResponse)).toStrictEqual(result);
});
});
@ -30,15 +30,18 @@ describe('Response message parser', () => {
});
});
describe('displaying only the first error when a response has messages for multiple users', () => {
const expected =
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.";
describe('displaying all errors when a response has messages for multiple users', () => {
it.each([
[{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }],
[{ data: invitationsApiResponse.EMAIL_RESTRICTED }],
])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => {
expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected);
[
{ data: invitationsApiResponse.MULTIPLE_RESTRICTED },
{ error: true, message: { ...invitationsApiResponse.MULTIPLE_RESTRICTED.message } },
],
[
{ data: invitationsApiResponse.EMAIL_RESTRICTED },
{ error: true, message: { ...invitationsApiResponse.EMAIL_RESTRICTED.message } },
],
])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse, result) => {
expect(responseFromSuccess(restrictedResponse)).toStrictEqual(result);
});
});
});

View File

@ -1,44 +1,59 @@
import Vue from 'vue';
import { GlSprintf, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import deleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue';
import DeleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue';
import eventHub from '~/milestones/event_hub';
import { redirectTo } from '~/lib/utils/url_utility';
import { createAlert } from '~/flash';
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
redirectTo: jest.fn(),
}));
jest.mock('~/lib/utils/url_utility');
jest.mock('~/flash');
describe('delete_milestone_modal.vue', () => {
const Component = Vue.extend(deleteMilestoneModal);
const props = {
describe('Delete milestone modal', () => {
let wrapper;
const mockProps = {
issueCount: 1,
mergeRequestCount: 2,
milestoneId: 3,
milestoneTitle: 'my milestone title',
milestoneUrl: `${TEST_HOST}/delete_milestone_modal.vue/milestone`,
};
let vm;
const findModal = () => wrapper.findComponent(GlModal);
const createComponent = (props) => {
wrapper = shallowMount(DeleteMilestoneModal, {
propsData: {
...mockProps,
...props,
},
stubs: {
GlSprintf,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('onSubmit', () => {
beforeEach(() => {
vm = mountComponent(Component, props);
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('deletes milestone and redirects to overview page', async () => {
const responseURL = `${TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
jest.spyOn(axios, 'delete').mockImplementation((url) => {
expect(url).toBe(props.milestoneUrl);
expect(url).toBe(mockProps.milestoneUrl);
expect(eventHub.$emit).toHaveBeenCalledWith(
'deleteMilestoneModal.requestStarted',
props.milestoneUrl,
mockProps.milestoneUrl,
);
eventHub.$emit.mockReset();
return Promise.resolve({
@ -47,55 +62,71 @@ describe('delete_milestone_modal.vue', () => {
},
});
});
await vm.onSubmit();
await findModal().vm.$emit('primary');
expect(redirectTo).toHaveBeenCalledWith(responseURL);
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
milestoneUrl: props.milestoneUrl,
milestoneUrl: mockProps.milestoneUrl,
successful: true,
});
});
it('displays error if deleting milestone failed', async () => {
const dummyError = new Error('deleting milestone failed');
dummyError.response = { status: 418 };
jest.spyOn(axios, 'delete').mockImplementation((url) => {
expect(url).toBe(props.milestoneUrl);
expect(eventHub.$emit).toHaveBeenCalledWith(
'deleteMilestoneModal.requestStarted',
props.milestoneUrl,
);
eventHub.$emit.mockReset();
return Promise.reject(dummyError);
});
it.each`
statusCode | alertMessage
${418} | ${`Failed to delete milestone ${mockProps.milestoneTitle}`}
${404} | ${`Milestone ${mockProps.milestoneTitle} was not found`}
`(
'displays error if deleting milestone failed with code $statusCode',
async ({ statusCode, alertMessage }) => {
const dummyError = new Error('deleting milestone failed');
dummyError.response = { status: statusCode };
jest.spyOn(axios, 'delete').mockImplementation((url) => {
expect(url).toBe(mockProps.milestoneUrl);
expect(eventHub.$emit).toHaveBeenCalledWith(
'deleteMilestoneModal.requestStarted',
mockProps.milestoneUrl,
);
eventHub.$emit.mockReset();
return Promise.reject(dummyError);
});
await expect(vm.onSubmit()).rejects.toEqual(dummyError);
expect(redirectTo).not.toHaveBeenCalled();
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
milestoneUrl: props.milestoneUrl,
successful: false,
});
});
await expect(wrapper.vm.onSubmit()).rejects.toEqual(dummyError);
expect(createAlert).toHaveBeenCalledWith({
message: alertMessage,
});
expect(redirectTo).not.toHaveBeenCalled();
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
milestoneUrl: mockProps.milestoneUrl,
successful: false,
});
},
);
});
describe('text', () => {
it('contains the issue and milestone count', () => {
vm = mountComponent(Component, props);
const value = vm.text;
describe('Modal title and description', () => {
const emptyDescription = `Youre about to permanently delete the milestone ${mockProps.milestoneTitle}. This milestone is not currently used in any issues or merge requests.`;
const description = `Youre about to permanently delete the milestone ${mockProps.milestoneTitle} and remove it from 1 issue and 2 merge requests. Once deleted, it cannot be undone or recovered.`;
const title = `Delete milestone ${mockProps.milestoneTitle}?`;
expect(value).toContain('remove it from 1 issue and 2 merge requests');
it('renders proper title', () => {
const value = findModal().props('title');
expect(value).toBe(title);
});
it('contains neither issue nor milestone count', () => {
vm = mountComponent(Component, {
...props,
issueCount: 0,
mergeRequestCount: 0,
});
it.each`
statement | descriptionText | issueCount | mergeRequestCount
${'1 issue and 2 merge requests'} | ${description} | ${1} | ${2}
${'no issues and merge requests'} | ${emptyDescription} | ${0} | ${0}
`(
'renders proper description when the milestone contains $statement',
({ issueCount, mergeRequestCount, descriptionText }) => {
createComponent({
issueCount,
mergeRequestCount,
});
const value = vm.text;
expect(value).toContain('is not currently used');
});
const value = findModal().text();
expect(value).toBe(descriptionText);
},
);
});
});

View File

@ -66,6 +66,7 @@ describe('WorkItemDetailModal component', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
isModal: true,
workItemId: '1',
workItemParentId: '2',
});
@ -98,6 +99,15 @@ describe('WorkItemDetailModal component', () => {
expect(wrapper.emitted('close')).toBeTruthy();
});
it('hides the modal when WorkItemDetail emits `close` event', () => {
createComponent();
const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
findWorkItemDetail().vm.$emit('close');
expect(closeSpy).toHaveBeenCalled();
});
describe('delete work item', () => {
it('emits workItemDeleted and closes modal', async () => {
createComponent();

View File

@ -1,4 +1,4 @@
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { GlAlert, GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@ -26,6 +26,7 @@ describe('WorkItemDetail component', () => {
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
const findCloseButton = () => wrapper.findComponent(GlButton);
const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const findWorkItemState = () => wrapper.findComponent(WorkItemState);
@ -34,6 +35,7 @@ describe('WorkItemDetail component', () => {
const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight);
const createComponent = ({
isModal = false,
workItemId = workItemQueryResponse.data.workItem.id,
handler = successHandler,
subscriptionHandler = initialSubscriptionHandler,
@ -51,7 +53,7 @@ describe('WorkItemDetail component', () => {
typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {},
},
),
propsData: { workItemId },
propsData: { isModal, workItemId },
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
@ -99,6 +101,36 @@ describe('WorkItemDetail component', () => {
});
});
describe('close button', () => {
describe('when isModal prop is false', () => {
it('does not render', async () => {
createComponent({ isModal: false });
await waitForPromises();
expect(findCloseButton().exists()).toBe(false);
});
});
describe('when isModal prop is true', () => {
it('renders', async () => {
createComponent({ isModal: true });
await waitForPromises();
expect(findCloseButton().props('icon')).toBe('close');
expect(findCloseButton().attributes('aria-label')).toBe('Close');
});
it('emits `close` event when clicked', async () => {
createComponent({ isModal: true });
await waitForPromises();
findCloseButton().vm.$emit('click');
expect(wrapper.emitted('close')).toEqual([[]]);
});
});
});
describe('description', () => {
it('does not show description widget if loading description fails', () => {
createComponent();

View File

@ -52,6 +52,7 @@ describe('Work items root component', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
isModal: false,
workItemId: 'gid://gitlab/WorkItem/1',
workItemParentId: null,
});

View File

@ -3,6 +3,34 @@
require 'spec_helper'
RSpec.describe Gitlab::Gpg::Commit do
let_it_be(:project) { create(:project, :repository, path: 'sample-project') }
let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
let(:committer_email) { GpgHelpers::User1.emails.first }
let(:user_email) { committer_email }
let(:public_key) { GpgHelpers::User1.public_key }
let(:user) { create(:user, email: user_email) }
let(:commit) { create(:commit, project: project, sha: commit_sha, committer_email: committer_email) }
let(:crypto) { instance_double(GPGME::Crypto) }
let(:mock_signature_data?) { true }
# gpg_keys must be pre-loaded so that they can be found during signature verification.
let!(:gpg_key) { create(:gpg_key, key: public_key, user: user) }
let(:signature_data) do
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
end
before do
if mock_signature_data?
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(signature_data)
end
end
describe '#signature' do
shared_examples 'returns the cached signature on second call' do
it 'returns the cached signature on second call' do
@ -17,11 +45,8 @@ RSpec.describe Gitlab::Gpg::Commit do
end
end
let!(:project) { create :project, :repository, path: 'sample-project' }
let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
context 'unsigned commit' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
let(:signature_data) { nil }
it 'returns nil' do
expect(described_class.new(commit).signature).to be_nil
@ -29,20 +54,12 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'invalid signature' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
# Corrupt the key
GpgHelpers::User1.signed_commit_signature.tr('=', 'a'),
GpgHelpers::User1.signed_commit_base_data
]
)
let(:signature_data) do
[
# Corrupt the key
GpgHelpers::User1.signed_commit_signature.tr('=', 'a'),
GpgHelpers::User1.signed_commit_base_data
]
end
it 'returns nil' do
@ -53,25 +70,6 @@ RSpec.describe Gitlab::Gpg::Commit do
context 'known key' do
context 'user matches the key uid' do
context 'user email matches the email committer' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
it 'returns a valid signature' do
signature = described_class.new(commit).signature
@ -112,32 +110,13 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'valid key signed using recent version of Gnupg' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
let!(:crypto) { instance_double(GPGME::Crypto) }
before do
fake_signature = [
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(fake_signature)
end
it 'returns a valid signature' do
verified_signature = double('verified-signature', fingerprint: GpgHelpers::User1.fingerprint, valid?: true)
allow(GPGME::Crypto).to receive(:new).and_return(crypto)
allow(crypto).to receive(:verify).and_yield(verified_signature)
end
it 'returns a valid signature' do
signature = described_class.new(commit).signature
expect(signature).to have_attributes(
@ -153,33 +132,14 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'valid key signed using older version of Gnupg' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
let!(:crypto) { instance_double(GPGME::Crypto) }
before do
fake_signature = [
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(fake_signature)
end
it 'returns a valid signature' do
keyid = GpgHelpers::User1.fingerprint.last(16)
verified_signature = double('verified-signature', fingerprint: keyid, valid?: true)
allow(GPGME::Crypto).to receive(:new).and_return(crypto)
allow(crypto).to receive(:verify).and_yield(verified_signature)
end
it 'returns a valid signature' do
signature = described_class.new(commit).signature
expect(signature).to have_attributes(
@ -195,32 +155,13 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'commit with multiple signatures' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
let!(:crypto) { instance_double(GPGME::Crypto) }
before do
fake_signature = [
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(fake_signature)
end
it 'returns an invalid signatures error' do
verified_signature = double('verified-signature', fingerprint: GpgHelpers::User1.fingerprint, valid?: true)
allow(GPGME::Crypto).to receive(:new).and_return(crypto)
allow(crypto).to receive(:verify).and_yield(verified_signature).and_yield(verified_signature)
end
it 'returns an invalid signatures error' do
signature = described_class.new(commit).signature
expect(signature).to have_attributes(
@ -236,27 +177,18 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'commit signed with a subkey' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User3.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User3.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User3.public_key, user: user
end
let(:committer_email) { GpgHelpers::User3.emails.first }
let(:public_key) { GpgHelpers::User3.public_key }
let(:gpg_key_subkey) do
gpg_key.subkeys.find_by(fingerprint: GpgHelpers::User3.subkey_fingerprints.last)
end
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User3.signed_commit_signature,
GpgHelpers::User3.signed_commit_base_data
]
)
let(:signature_data) do
[
GpgHelpers::User3.signed_commit_signature,
GpgHelpers::User3.signed_commit_base_data
]
end
it 'returns a valid signature' do
@ -275,7 +207,7 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'user email does not match the committer email, but is the same user' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first }
let(:committer_email) { GpgHelpers::User2.emails.first }
let(:user) do
create(:user, email: GpgHelpers::User1.emails.first).tap do |user|
@ -283,21 +215,6 @@ RSpec.describe Gitlab::Gpg::Commit do
end
end
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
it 'returns an invalid signature' do
expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha,
@ -314,24 +231,8 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'user email does not match the committer email' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first }
let(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
let(:committer_email) { GpgHelpers::User2.emails.first }
let(:user_email) { GpgHelpers::User1.emails.first }
it 'returns an invalid signature' do
expect(described_class.new(commit).signature).to have_attributes(
@ -350,24 +251,8 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'user does not match the key uid' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
let(:user) { create(:user, email: GpgHelpers::User2.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
let(:user_email) { GpgHelpers::User2.emails.first }
let(:public_key) { GpgHelpers::User1.public_key }
it 'returns an invalid signature' do
expect(described_class.new(commit).signature).to have_attributes(
@ -386,18 +271,7 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'unknown key' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
before do
allow(Gitlab::Git::Commit).to receive(:extract_signature_lazily)
.with(Gitlab::Git::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
let(:gpg_key) { nil }
it 'returns an invalid signature' do
expect(described_class.new(commit).signature).to have_attributes(
@ -415,15 +289,15 @@ RSpec.describe Gitlab::Gpg::Commit do
end
context 'multiple commits with signatures' do
let(:first_signature) { create(:gpg_signature) }
let(:gpg_key) { create(:gpg_key, key: GpgHelpers::User2.public_key) }
let(:second_signature) { create(:gpg_signature, gpg_key: gpg_key) }
let(:mock_signature_data?) { false }
let!(:first_signature) { create(:gpg_signature) }
let!(:gpg_key) { create(:gpg_key, key: GpgHelpers::User2.public_key) }
let!(:second_signature) { create(:gpg_signature, gpg_key: gpg_key) }
let!(:first_commit) { create(:commit, project: project, sha: first_signature.commit_sha) }
let!(:second_commit) { create(:commit, project: project, sha: second_signature.commit_sha) }
let(:commits) do
let!(:commits) do
[first_commit, second_commit].map do |commit|
gpg_commit = described_class.new(commit)
@ -442,4 +316,21 @@ RSpec.describe Gitlab::Gpg::Commit do
end
end
end
describe '#update_signature!' do
let!(:gpg_key) { nil }
let(:signature) { described_class.new(commit).signature }
it 'updates signature record' do
signature
create(:gpg_key, key: public_key, user: user)
stored_signature = CommitSignatures::GpgSignature.find_by_commit_sha(commit_sha)
expect { described_class.new(commit).update_signature!(stored_signature) }.to(
change { signature.reload.verification_status }.from('unknown_key').to('verified')
)
end
end
end

View File

@ -2,14 +2,21 @@
require 'spec_helper'
RSpec.describe Gitlab::X509::Commit do
let(:commit_sha) { '189a6c924013fc3fe40d6f1ec1dc20214183bc97' }
let(:user) { create(:user, email: X509Helpers::User1.certificate_email) }
let(:project) { create(:project, :repository, path: X509Helpers::User1.path, creator: user) }
let(:commit) { project.commit_by(oid: commit_sha ) }
let(:signature) { Gitlab::X509::Commit.new(commit).signature }
let(:store) { OpenSSL::X509::Store.new }
let(:certificate) { OpenSSL::X509::Certificate.new(X509Helpers::User1.trust_cert) }
before do
store.add_cert(certificate) if certificate
allow(OpenSSL::X509::Store).to receive(:new).and_return(store)
end
describe '#signature' do
let(:signature) { described_class.new(commit).signature }
context 'returns the cached signature' do
let(:commit_sha) { '189a6c924013fc3fe40d6f1ec1dc20214183bc97' }
let(:project) { create(:project, :public, :repository) }
let(:commit) { create(:commit, project: project, sha: commit_sha) }
it 'on second call' do
allow_any_instance_of(described_class).to receive(:new).and_call_original
expect_any_instance_of(described_class).to receive(:create_cached_signature!).and_call_original
@ -23,13 +30,29 @@ RSpec.describe Gitlab::X509::Commit do
end
context 'unsigned commit' do
let!(:project) { create :project, :repository, path: X509Helpers::User1.path }
let!(:commit_sha) { X509Helpers::User1.commit }
let!(:commit) { create :commit, project: project, sha: commit_sha }
let(:project) { create :project, :repository, path: X509Helpers::User1.path }
let(:commit_sha) { X509Helpers::User1.commit }
let(:commit) { create :commit, project: project, sha: commit_sha }
it 'returns nil' do
expect(signature).to be_nil
end
end
end
describe '#update_signature!' do
let(:certificate) { nil }
it 'updates verification status' do
signature
cert = OpenSSL::X509::Certificate.new(X509Helpers::User1.trust_cert)
store.add_cert(cert)
stored_signature = CommitSignatures::X509CommitSignature.find_by_commit_sha(commit_sha)
expect { described_class.new(commit).update_signature!(stored_signature) }.to(
change { signature.reload.verification_status }.from('unverified').to('verified')
)
end
end
end

View File

@ -9,18 +9,28 @@ module Spec
click_on 'Invite members'
page.within invite_modal_selector do
Array.wrap(names).each do |name|
find(member_dropdown_selector).set(name)
wait_for_requests
click_button name
end
select_members(names)
choose_options(role, expires_at)
click_button 'Invite'
end
page.refresh if refresh
page.refresh if refresh
end
def input_invites(names)
click_on 'Invite members'
page.within invite_modal_selector do
select_members(names)
end
end
def select_members(names)
Array.wrap(names).each do |name|
find(member_dropdown_selector).set(name)
wait_for_requests
click_button name
end
end
@ -64,6 +74,24 @@ module Spec
'[data-testid="invite-modal"]'
end
def member_token_error_selector(id)
"[data-testid='error-icon-#{id}']"
end
def member_token_avatar_selector
"[data-testid='token-avatar']"
end
def member_token_selector(id)
"[data-token-id='#{id}']"
end
def remove_token(id)
page.within member_token_selector(id) do
find('[data-testid="close-icon"]').click
end
end
def expect_to_have_group(group)
expect(page).to have_selector("[entity-id='#{group.id}']")
end

View File

@ -15,13 +15,6 @@ module Spec
execute_script("monaco.editor.getModel('#{uri}').setValue('#{escape_javascript(value)}')")
end
def editor_get_value
editor = find('.monaco-editor')
uri = editor['data-uri']
evaluate_script("monaco.editor.getModel('#{uri}').getValue()")
end
end
end
end

View File

@ -100,10 +100,6 @@ module WebIdeSpecHelpers
editor_set_value(value)
end
def ide_editor_value
editor_get_value
end
def ide_commit_tab_selector
ide_tab_selector('commit')
end

View File

@ -23,6 +23,22 @@ RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
)
end
it 'displays the user\'s avatar in the member input token', :js do
visit members_page_path
input_invites(user2.name)
expect(page).to have_selector(member_token_avatar_selector)
end
it 'does not display an avatar in the member input token for an email address', :js do
visit members_page_path
input_invites('test@example.com')
expect(page).not_to have_selector(member_token_avatar_selector)
end
it 'invites user by email', :js, :snowplow, :aggregate_failures do
visit members_page_path
@ -79,11 +95,13 @@ RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
context 'when member is already a member by email' do
it 'updates the member for that email', :js do
email = 'test@example.com'
visit members_page_path
invite_member('test@example.com', role: 'Developer')
invite_member(email, role: 'Developer')
invite_member('test@example.com', role: 'Reporter', refresh: false)
invite_member(email, role: 'Reporter', refresh: false)
expect(page).not_to have_selector(invite_modal_selector)
@ -91,7 +109,7 @@ RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
click_link 'Invited'
page.within find_invited_member_row('test@example.com') do
page.within find_invited_member_row(email) do
expect(page).to have_button('Reporter')
end
end
@ -130,8 +148,8 @@ RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
invite_member(user2.name, role: role, refresh: false)
expect(page).to have_selector(invite_modal_selector)
expect(page).to have_content "Access level should be greater than or equal to Developer inherited membership " \
"from group #{group.name}"
expect(page).to have_content "#{user2.name}: Access level should be greater than or equal to Developer " \
"inherited membership from group #{group.name}"
page.refresh
@ -148,13 +166,31 @@ RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
group.add_maintainer(user3)
end
it 'only shows the first user error', :js do
it 'shows the user errors and then removes them from the form', :js do
visit subentity_members_page_path
invite_member([user2.name, user3.name], role: role, refresh: false)
expect(page).to have_selector(invite_modal_selector)
expect(page).to have_text("Access level should be greater than or equal to", count: 1)
expect(page).to have_selector(member_token_error_selector(user2.id))
expect(page).to have_selector(member_token_error_selector(user3.id))
expect(page).to have_text("The following 2 members couldn't be invited")
expect(page).to have_text("#{user2.name}: Access level should be greater than or equal to")
expect(page).to have_text("#{user3.name}: Access level should be greater than or equal to")
remove_token(user2.id)
expect(page).not_to have_selector(member_token_error_selector(user2.id))
expect(page).to have_selector(member_token_error_selector(user3.id))
expect(page).to have_text("The following member couldn't be invited")
expect(page).not_to have_text("#{user2.name}: Access level should be greater than or equal to")
remove_token(user3.id)
expect(page).not_to have_selector(member_token_error_selector(user3.id))
expect(page).not_to have_text("The following member couldn't be invited")
expect(page).not_to have_text("Review the invite errors and try again")
expect(page).not_to have_text("#{user3.name}: Access level should be greater than or equal to")
page.refresh
@ -168,6 +204,19 @@ RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
expect(page).not_to have_button('Maintainer')
end
end
it 'only shows the error for an invalid formatted email and does not display other member errors', :js do
visit subentity_members_page_path
invite_member([user2.name, user3.name, 'bad@email'], role: role, refresh: false)
expect(page).to have_selector(invite_modal_selector)
expect(page).to have_text('email contains an invalid email address')
expect(page).not_to have_text("The following 2 members couldn't be invited")
expect(page).not_to have_text("Review the invite errors and try again")
expect(page).not_to have_text("#{user2.name}: Access level should be greater than or equal to")
expect(page).not_to have_text("#{user3.name}: Access level should be greater than or equal to")
end
end
end
end