Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-09-07 09:08:17 +00:00
parent f675c7d41d
commit 444f662b8d
33 changed files with 569 additions and 305 deletions

View File

@ -105,8 +105,8 @@ export default {
atVersion: this.designsVersion,
};
},
isDiscussionHighlighted() {
return this.discussion.notes[0].id === this.activeDiscussion.id;
isDiscussionActive() {
return this.discussion.notes.some(({ id }) => id === this.activeDiscussion.id);
},
resolveCheckboxText() {
return this.discussion.resolved
@ -134,18 +134,6 @@ export default {
isFormVisible() {
return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id;
},
shouldScrollToDiscussion(activeDiscussion) {
const ALLOWED_ACTIVE_DISCUSSION_SOURCES = [
ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
ACTIVE_DISCUSSION_SOURCE_TYPES.url,
];
const { id: activeDiscussionId, source: activeDiscussionSource } = activeDiscussion;
return (
ALLOWED_ACTIVE_DISCUSSION_SOURCES.includes(activeDiscussionSource) &&
activeDiscussionId === this.discussion.notes[0].id
);
},
},
methods: {
addDiscussionComment(
@ -199,6 +187,14 @@ export default {
this.isResolving = false;
});
},
shouldScrollToDiscussion(activeDiscussion) {
const ALLOWED_ACTIVE_DISCUSSION_SOURCES = [
ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
ACTIVE_DISCUSSION_SOURCE_TYPES.url,
];
const { source } = activeDiscussion;
return ALLOWED_ACTIVE_DISCUSSION_SOURCES.includes(source) && this.isDiscussionActive;
},
},
createNoteMutation,
};
@ -221,7 +217,7 @@ export default {
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('updateNoteError', $event)"
>
<template v-if="discussion.resolvable" #resolveDiscussion>
@ -265,7 +261,7 @@ export default {
:note="note"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('updateNoteError', $event)"
/>
<li v-show="isReplyPlaceholderVisible" class="reply-wrapper">

View File

@ -7,7 +7,7 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignReplyForm from './design_reply_form.vue';
import { findNoteId } from '../../utils/design_management_utils';
import { findNoteId, extractDesignNoteId } from '../../utils/design_management_utils';
import { hasErrors } from '../../utils/cache_update';
export default {
@ -47,7 +47,7 @@ export default {
return findNoteId(this.note.id);
},
isNoteLinked() {
return this.$route.hash === `#note_${this.noteAnchorId}`;
return extractDesignNoteId(this.$route.hash) === this.noteAnchorId;
},
mutationPayload() {
return {
@ -59,13 +59,6 @@ export default {
return !this.isEditing && this.note.userPermissions.adminNote;
},
},
mounted() {
this.$nextTick(() => {
if (this.isNoteLinked) {
this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
}
});
},
methods: {
hideForm() {
this.isEditing = false;

View File

@ -237,7 +237,12 @@ export default {
});
},
isNoteInactive(note) {
return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
const discussionNotes = note.discussion.notes.nodes || [];
return (
this.activeDiscussion.id &&
!discussionNotes.some(({ id }) => id === this.activeDiscussion.id)
);
},
designPinClass(note) {
return { inactive: this.isNoteInactive(note), resolved: note.resolved };

View File

@ -25,5 +25,10 @@ fragment DesignNote on Note {
}
discussion {
id
notes {
nodes {
id
}
}
}
}

View File

@ -11,11 +11,11 @@
- if auth_active?(provider)
- if unlink_allowed
= link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do
= s_('Profiles|Disconnect')
= s_('Profiles|Disconnect %{provider}') % { provider: label_for_provider(provider) }
- else
%a.provider-btn
= s_('Profiles|Active')
= s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) }
- elsif link_allowed
= link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn gl-text-blue-500' do
= s_('Profiles|Connect')
= s_('Profiles|Connect %{provider}') % { provider: label_for_provider(provider) }
= render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities

View File

@ -0,0 +1,5 @@
---
title: Add job token authentication for the GitLab PyPI package repository
merge_request: 40888
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Drop code_owner column from approval_merge_request_rules
merge_request: 40322
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Display provider name for profile social sign-in connectors
merge_request: 41198
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Highlight design discussion if any comment in discussion is linked
merge_request: 41062
author:
type: fixed

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class DropCodeOwnerColumnFromApprovalMergeRequestRule < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
with_lock_retries do
remove_column :approval_merge_request_rules, :code_owner
end
end
def down
unless column_exists?(:approval_merge_request_rules, :code_owner)
with_lock_retries do
add_column :approval_merge_request_rules, :code_owner, :boolean, default: false, null: false
end
end
add_concurrent_index(
:approval_merge_request_rules,
[:merge_request_id, :code_owner, :name],
unique: true,
where: "code_owner = true AND section IS NULL",
name: "approval_rule_name_index_for_code_owners"
)
add_concurrent_index(
:approval_merge_request_rules,
[:merge_request_id, :code_owner],
name: "index_approval_merge_request_rules_1"
)
end
end

View File

@ -0,0 +1 @@
6fb93002ffd5c1d1bfff5bea8a99cbbfc7cefefbc450a9d067ee0cfab8d11e9e

View File

@ -9304,7 +9304,6 @@ CREATE TABLE public.approval_merge_request_rules (
updated_at timestamp with time zone NOT NULL,
merge_request_id integer NOT NULL,
approvals_required smallint DEFAULT 0 NOT NULL,
code_owner boolean DEFAULT false NOT NULL,
name character varying NOT NULL,
rule_type smallint DEFAULT 1 NOT NULL,
report_type smallint,
@ -19048,8 +19047,6 @@ CREATE UNIQUE INDEX any_approver_project_rule_type_unique_index ON public.approv
CREATE INDEX approval_mr_rule_index_merge_request_id ON public.approval_merge_request_rules USING btree (merge_request_id);
CREATE UNIQUE INDEX approval_rule_name_index_for_code_owners ON public.approval_merge_request_rules USING btree (merge_request_id, code_owner, name) WHERE ((code_owner = true) AND (section IS NULL));
CREATE UNIQUE INDEX backup_labels_group_id_project_id_title_idx ON public.backup_labels USING btree (group_id, project_id, title);
CREATE INDEX backup_labels_group_id_title_idx ON public.backup_labels USING btree (group_id, title) WHERE (project_id = NULL::integer);
@ -19222,8 +19219,6 @@ CREATE UNIQUE INDEX index_approval_merge_request_rule_sources_1 ON public.approv
CREATE INDEX index_approval_merge_request_rule_sources_2 ON public.approval_merge_request_rule_sources USING btree (approval_project_rule_id);
CREATE INDEX index_approval_merge_request_rules_1 ON public.approval_merge_request_rules USING btree (merge_request_id, code_owner);
CREATE UNIQUE INDEX index_approval_merge_request_rules_approved_approvers_1 ON public.approval_merge_request_rules_approved_approvers USING btree (approval_merge_request_rule_id, user_id);
CREATE INDEX index_approval_merge_request_rules_approved_approvers_2 ON public.approval_merge_request_rules_approved_approvers USING btree (user_id);

View File

@ -51,12 +51,17 @@ Using the consolidated object storage configuration has a number of advantages:
- It enables the use of [encrypted S3 buckets](#encrypted-s3-buckets).
- It [uploads files to S3 with proper `Content-MD5` headers](https://gitlab.com/gitlab-org/gitlab-workhorse/-/issues/222).
NOTE: **Note:**
Only AWS S3-compatible providers and Google are
supported at the moment since [direct upload
mode](../development/uploads.md#direct-upload) must be used. Background
upload is not supported in this mode. We recommend direct upload mode because
it does not require a shared folder, and [this setting may become the default](https://gitlab.com/gitlab-org/gitlab/-/issues/27331).
Because [direct upload mode](../development/uploads.md#direct-upload)
must be enabled, only the following providers can be used:
- [Amazon S3-compatible providers](#s3-compatible-connection-settings)
- [Google Cloud Storage](#google-cloud-storage-gcs)
- [Azure Blob storage](#azure-blob-storage)
Background upload is not supported with the consolidated object storage
configuration. We recommend enabling direct upload mode because it does
not require a shared folder, and [this setting may become the
default](https://gitlab.com/gitlab-org/gitlab/-/issues/27331).
NOTE: **Note:**
Consolidated object storage configuration cannot be used for

View File

@ -266,7 +266,7 @@ POST /groups/:id/epics
| `title` | string | yes | The title of the epic |
| `labels` | string | no | The comma separated list of labels |
| `description` | string | no | The description of the epic. Limited to 1,048,576 characters. |
| `confidential` | boolean | no | Whether the epic should be confidential. Will be ignored if `confidential_epics` feature flag is disabled. |
| `confidential` | boolean | no | Whether the epic should be confidential |
| `start_date_is_fixed` | boolean | no | Whether start date should be sourced from `start_date_fixed` or from milestones (since 11.3) |
| `start_date_fixed` | string | no | The fixed start date of an epic (since 11.3) |
| `due_date_is_fixed` | boolean | no | Whether due date should be sourced from `due_date_fixed` or from milestones (since 11.3) |
@ -347,7 +347,7 @@ PUT /groups/:id/epics/:epic_iid
| `epic_iid` | integer/string | yes | The internal ID of the epic |
| `title` | string | no | The title of an epic |
| `description` | string | no | The description of an epic. Limited to 1,048,576 characters. |
| `confidential` | boolean | no | Whether the epic should be confidential. Will be ignored if `confidential_epics` feature flag is disabled. |
| `confidential` | boolean | no | Whether the epic should be confidential |
| `labels` | string | no | The comma separated list of labels |
| `start_date_is_fixed` | boolean | no | Whether start date should be sourced from `start_date_fixed` or from milestones (since 11.3) |
| `start_date_fixed` | string | no | The fixed start date of an epic (since 11.3) |

View File

@ -2614,7 +2614,7 @@ input CreateEpicInput {
clientMutationId: String
"""
Indicates if the epic is confidential. Will be ignored if `confidential_epics` feature flag is disabled
Indicates if the epic is confidential
"""
confidential: Boolean
@ -16809,7 +16809,7 @@ input UpdateEpicInput {
clientMutationId: String
"""
Indicates if the epic is confidential. Will be ignored if `confidential_epics` feature flag is disabled
Indicates if the epic is confidential
"""
confidential: Boolean

View File

@ -7089,7 +7089,7 @@
},
{
"name": "confidential",
"description": "Indicates if the epic is confidential. Will be ignored if `confidential_epics` feature flag is disabled",
"description": "Indicates if the epic is confidential",
"type": {
"kind": "SCALAR",
"name": "Boolean",
@ -49522,7 +49522,7 @@
},
{
"name": "confidential",
"description": "Indicates if the epic is confidential. Will be ignored if `confidential_epics` feature flag is disabled",
"description": "Indicates if the epic is confidential",
"type": {
"kind": "SCALAR",
"name": "Boolean",

View File

@ -164,18 +164,6 @@ To make an epic confidential:
- **In an existing epic:** in the epic's sidebar, select **Edit** next to **Confidentiality** then
select **Turn on**.
### Disable confidential epics **(PREMIUM ONLY)**
The confidential epics feature is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can disable it for your self-managed instance.
To disable it:
```ruby
Feature.disable(:confidential_epics)
```
## Manage issues assigned to an epic
### Add a new issue to an epic

View File

@ -26,19 +26,19 @@ For information on how to create and upload a package, view the GitLab documenta
## Use GitLab CI/CD to build packages
You can use [GitLab CI/CD](../../../ci/README.md) to build packages.
For Maven, NuGet and NPM packages, and Composer dependencies, you can
For Maven, NuGet, NPM, Conan, and PyPI packages, and Composer dependencies, you can
authenticate with GitLab by using the `CI_JOB_TOKEN`.
CI/CD templates, which you can use to get started, are in [this repo](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates).
Learn more about [using CI/CD to build Maven packages](../maven_repository/index.md#creating-maven-packages-with-gitlab-cicd), [NPM packages](../npm_registry/index.md#publishing-a-package-with-cicd), [Composer packages](../composer_repository/index.md#publishing-the-package-with-cicd), and [NuGet packages](../nuget_repository/index.md#publishing-a-nuget-package-with-cicd).
Learn more about [using CI/CD to build Maven packages](../maven_repository/index.md#creating-maven-packages-with-gitlab-cicd), [NPM packages](../npm_registry/index.md#publishing-a-package-with-cicd), [Composer packages](../composer_repository/index.md#publishing-the-package-with-cicd), [NuGet Packages](../nuget_repository/index.md#publishing-a-nuget-package-with-cicd), [Conan Packages](../conan_repository/index.md#using-gitlab-ci-with-conan-packages), and [PyPI packages](../pypi_repository/index.md#using-gitlab-ci-with-pypi-packages).
If you use CI/CD to build a package, extended activity
information is displayed when you view the package details:
![Package CI/CD activity](img/package_activity_v12_10.png)
You can view which pipeline published the package, as well as the commit and
When using Maven and NPM, you can view which pipeline published the package, as well as the commit and
user who triggered it.
## Download a package

View File

@ -302,20 +302,10 @@ Successfully installed mypypipackage-0.0.1
## Using GitLab CI with PyPI packages
NOTE: **Note:**
`CI_JOB_TOKEN`s are not yet supported for use with PyPI.
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/11678) in GitLab 13.4.
To work with PyPI commands within [GitLab CI/CD](./../../../ci/README.md), you can use
[environment variables](./../../../ci/variables/README.md#custom-environment-variables)
to access your authentication tokens in your commands.
Set up environment variables for `TWINE_PASSWORD` and `TWINE_USERNAME` using either:
- A [personal access token](../../../user/profile/personal_access_tokens.md) and your GitLab username.
- A [deploy token](./../../project/deploy_tokens/index.md) and its associated deploy token username.
You can now access your `TWINE_USERNAME` and `TWINE_PASSWORD` using any `twine` command in your
`.gitlab-ci.yml` file.
`CI_JOB_TOKEN` in place of the personal access token or deploy token in your commands.
For example:
@ -326,5 +316,18 @@ run:
script:
- pip install twine
- python setup.py sdist bdist_wheel
- TWINE_PASSWORD=${TWINE_PASSWORD} TWINE_USERNAME=${TWINE_USERNAME} python -m twine upload --repository-url https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/pypi dist/*
- TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token python -m twine upload --repository-url https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/pypi dist/*
```
You can also use `CI_JOB_TOKEN` in a `~/.pypirc` file that you check into GitLab:
```ini
[distutils]
index-servers =
gitlab
[gitlab]
repository = https://gitlab.com/api/v4/projects/${env.CI_PROJECT_ID}/packages/pypi
username = gitlab-ci-token
password = ${env.CI_JOB_TOKEN}
```

View File

@ -64,7 +64,7 @@ module API
requires :sha256, type: String, desc: 'The PyPi package sha256 check sum'
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'files/:sha256/*file_identifier' do
project = unauthorized_user_project!
@ -87,7 +87,7 @@ module API
# An Api entry point but returns an HTML file instead of JSON.
# PyPi simple API returns the package descriptor as a simple HTML file.
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
get 'simple/*package_name', format: :txt do
authorize_read_package!(authorized_user_project)
@ -117,7 +117,7 @@ module API
optional :sha256_digest, type: String
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
post do
authorize_upload!(authorized_user_project)
bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size)
@ -135,7 +135,7 @@ module API
forbidden!
end
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true
route_setting :authentication, deploy_token_allowed: true, basic_auth_personal_access_token: true, job_token_allowed: :basic_auth
post 'authorize' do
authorize_workhorse!(
subject: authorized_user_project,

View File

@ -18783,6 +18783,9 @@ msgstr ""
msgid "Profiles| You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. Please update your Git repository remotes as soon as possible."
msgstr ""
msgid "Profiles|%{provider} Active"
msgstr ""
msgid "Profiles|@username"
msgstr ""
@ -18834,7 +18837,7 @@ msgstr ""
msgid "Profiles|Commit email"
msgstr ""
msgid "Profiles|Connect"
msgid "Profiles|Connect %{provider}"
msgstr ""
msgid "Profiles|Connected Accounts"
@ -18858,6 +18861,9 @@ msgstr ""
msgid "Profiles|Disconnect"
msgstr ""
msgid "Profiles|Disconnect %{provider}"
msgstr ""
msgid "Profiles|Do not show on profile"
msgstr ""

View File

@ -22,7 +22,12 @@ RSpec.describe 'Merge request > User assigns themselves' do
end
it 'updates updated_by', :js do
expect { click_button 'assign yourself' }.to change { merge_request.reload.updated_at }
expect do
click_button 'assign yourself'
expect(find('.assignee')).to have_content(user.name)
wait_for_all_requests
end.to change { merge_request.reload.updated_at }
end
it 'returns user to the merge request', :js do

View File

@ -9,6 +9,39 @@ RSpec.describe 'Profile > Account', :js do
sign_in(user)
end
describe 'Social sign-in' do
context 'when an identity does not exist' do
before do
allow(Devise).to receive_messages(omniauth_configs: { google_oauth2: {} })
end
it 'allows the user to connect' do
visit profile_account_path
expect(page).to have_link('Connect Google', href: '/users/auth/google_oauth2')
end
end
context 'when an identity already exists' do
before do
allow(Devise).to receive_messages(omniauth_configs: { twitter: {}, saml: {} })
create(:identity, user: user, provider: :twitter)
create(:identity, user: user, provider: :saml)
visit profile_account_path
end
it 'allows the user to disconnect when there is an existing identity' do
expect(page).to have_link('Disconnect Twitter', href: '/profile/account/unlink?provider=twitter')
end
it 'shows active for a provider that is not allowed to unlink' do
expect(page).to have_content('Saml Active')
end
end
end
describe 'Change username' do
let(:new_username) { 'bar' }
let(:new_user_path) { "/#{new_username}" }

View File

@ -8,8 +8,9 @@ import createNoteMutation from '~/design_management/graphql/mutations/create_not
import toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue';
import mockDiscussion from '../../mock_data/discussion';
const discussion = {
const defaultMockDiscussion = {
id: '0',
resolved: false,
resolvable: true,
@ -49,7 +50,7 @@ describe('Design discussions component', () => {
wrapper = mount(DesignDiscussion, {
propsData: {
resolvedDiscussionsExpanded: true,
discussion,
discussion: defaultMockDiscussion,
noteableId: 'noteable-id',
designId: 'design-id',
discussionIndex: 1,
@ -82,7 +83,7 @@ describe('Design discussions component', () => {
beforeEach(() => {
createComponent({
discussion: {
...discussion,
...defaultMockDiscussion,
resolvable: false,
},
});
@ -125,7 +126,7 @@ describe('Design discussions component', () => {
it('renders a checkbox with Resolve thread text in reply form', () => {
findReplyPlaceholder().vm.$emit('onClick');
wrapper.setProps({ discussionWithOpenForm: discussion.id });
wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Resolve thread');
@ -141,7 +142,7 @@ describe('Design discussions component', () => {
beforeEach(() => {
createComponent({
discussion: {
...discussion,
...defaultMockDiscussion,
resolved: true,
resolvedBy: notes[0].author,
resolvedAt: '2020-05-08T07:10:45Z',
@ -206,7 +207,7 @@ describe('Design discussions component', () => {
it('renders a checkbox with Unresolve thread text in reply form', () => {
findReplyPlaceholder().vm.$emit('onClick');
wrapper.setProps({ discussionWithOpenForm: discussion.id });
wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findResolveCheckbox().text()).toBe('Unresolve thread');
@ -218,7 +219,7 @@ describe('Design discussions component', () => {
it('hides reply placeholder and opens form on placeholder click', () => {
createComponent();
findReplyPlaceholder().vm.$emit('onClick');
wrapper.setProps({ discussionWithOpenForm: discussion.id });
wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id });
return wrapper.vm.$nextTick().then(() => {
expect(findReplyPlaceholder().exists()).toBe(false);
@ -228,7 +229,7 @@ describe('Design discussions component', () => {
it('calls mutation on submitting form and closes the form', () => {
createComponent(
{ discussionWithOpenForm: discussion.id },
{ discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
@ -246,7 +247,7 @@ describe('Design discussions component', () => {
it('clears the discussion comment on closing comment form', () => {
createComponent(
{ discussionWithOpenForm: discussion.id },
{ discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
@ -263,19 +264,26 @@ describe('Design discussions component', () => {
});
});
it('applies correct class to design notes when discussion is highlighted', () => {
createComponent(
{},
{
activeDiscussion: {
id: notes[0].id,
source: 'pin',
},
},
);
describe('when any note from a discussion is active', () => {
it.each([notes[0], notes[0].discussion.notes.nodes[1]])(
'applies correct class to all notes in the active discussion',
note => {
createComponent(
{ discussion: mockDiscussion },
{
activeDiscussion: {
id: note.id,
source: 'pin',
},
},
);
expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe(
true,
expect(
wrapper
.findAll(DesignNote)
.wrappers.every(designNote => designNote.classes('gl-bg-blue-50')),
).toBe(true);
},
);
});
@ -285,7 +293,7 @@ describe('Design discussions component', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: toggleResolveDiscussionMutation,
variables: {
id: discussion.id,
id: defaultMockDiscussion.id,
resolve: true,
},
});
@ -296,7 +304,7 @@ describe('Design discussions component', () => {
it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => {
createComponent(
{ discussionWithOpenForm: discussion.id },
{ discussionWithOpenForm: defaultMockDiscussion.id },
{ discussionComment: 'test', isFormRendered: true },
);
findResolveButton().trigger('click');
@ -306,7 +314,7 @@ describe('Design discussions component', () => {
expect(mutate).toHaveBeenCalledWith({
mutation: toggleResolveDiscussionMutation,
variables: {
id: discussion.id,
id: defaultMockDiscussion.id,
resolve: true,
},
});

View File

@ -86,16 +86,6 @@ describe('Design note component', () => {
expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
});
it('should trigger a scrollIntoView method', () => {
createComponent({
note,
});
return wrapper.vm.$nextTick().then(() => {
expect(scrollIntoViewMock).toHaveBeenCalled();
});
});
it('should not render edit icon when user does not have a permission', () => {
createComponent({
note,

View File

@ -13,8 +13,9 @@ describe('Design overlay component', () => {
const findAllNotes = () => wrapper.findAll('.js-image-badge');
const findCommentBadge = () => wrapper.find('.comment-indicator');
const findFirstBadge = () => findAllNotes().at(0);
const findSecondBadge = () => findAllNotes().at(1);
const findBadgeAtIndex = noteIndex => findAllNotes().at(noteIndex);
const findFirstBadge = () => findBadgeAtIndex(0);
const findSecondBadge = () => findBadgeAtIndex(1);
const clickAndDragBadge = (elem, fromPoint, toPoint) => {
elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
@ -104,16 +105,43 @@ describe('Design overlay component', () => {
expect(findSecondBadge().classes()).toContain('resolved');
});
it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => {
wrapper.setData({
activeDiscussion: {
id: notes[0].id,
source: 'discussion',
},
describe('when no discussion is active', () => {
it('should not apply inactive class to any pins', () => {
expect(
findAllNotes(0).wrappers.every(designNote => designNote.classes('gl-bg-blue-50')),
).toBe(false);
});
});
return wrapper.vm.$nextTick().then(() => {
expect(findSecondBadge().classes()).toContain('inactive');
describe('when a discussion is active', () => {
it.each([notes[0].discussion.notes.nodes[1], notes[0].discussion.notes.nodes[0]])(
'should not apply inactive class to the pin for the active discussion',
note => {
wrapper.setData({
activeDiscussion: {
id: note.id,
source: 'discussion',
},
});
return wrapper.vm.$nextTick().then(() => {
expect(findBadgeAtIndex(0).classes()).not.toContain('inactive');
});
},
);
it('should apply inactive class to all pins besides the active one', () => {
wrapper.setData({
activeDiscussion: {
id: notes[0].id,
source: 'discussion',
},
});
return wrapper.vm.$nextTick().then(() => {
expect(findSecondBadge().classes()).toContain('inactive');
expect(findFirstBadge().classes()).not.toContain('inactive');
});
});
});
});

View File

@ -0,0 +1,45 @@
export default {
id: 'discussion-id-1',
resolved: false,
resolvable: true,
notes: [
{
id: 'note-id-1',
index: 1,
position: {
height: 100,
width: 100,
x: 10,
y: 15,
},
author: {
name: 'John',
webUrl: 'link-to-john-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
resolved: false,
},
{
id: 'note-id-3',
index: 3,
position: {
height: 50,
width: 50,
x: 25,
y: 25,
},
author: {
name: 'Mary',
webUrl: 'link-to-mary-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
resolved: false,
},
],
};

View File

@ -1,46 +1,44 @@
import DISCUSSION_1 from './discussion';
const DISCUSSION_2 = {
id: 'discussion-id-2',
notes: {
nodes: [
{
id: 'note-id-2',
index: 2,
position: {
height: 50,
width: 50,
x: 25,
y: 25,
},
author: {
name: 'Mary',
webUrl: 'link-to-mary-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
resolved: true,
},
],
},
};
export default [
{
id: 'note-id-1',
index: 1,
position: {
height: 100,
width: 100,
x: 10,
y: 15,
},
author: {
name: 'John',
webUrl: 'link-to-john-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
...DISCUSSION_1.notes[0],
discussion: {
id: 'discussion-id-1',
id: DISCUSSION_1.id,
notes: {
nodes: DISCUSSION_1.notes,
},
},
resolved: false,
},
{
id: 'note-id-2',
index: 2,
position: {
height: 50,
width: 50,
x: 25,
y: 25,
},
author: {
name: 'Mary',
webUrl: 'link-to-mary-profile',
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
adminNote: true,
},
discussion: {
id: 'discussion-id-2',
},
resolved: true,
...DISCUSSION_2.notes.nodes[0],
discussion: DISCUSSION_2,
},
];

View File

@ -1,141 +0,0 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import VueDraggable from 'vuedraggable';
import Design from '~/design_management/components/list/item.vue';
import createRouter from '~/design_management/router';
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import createMockApollo from '../../helpers/mock_apollo_helper';
import Index from '~/design_management/pages/index.vue';
import {
designListQueryResponse,
permissionsQueryResponse,
moveDesignMutationResponse,
reorderedDesigns,
moveDesignMutationResponseWithErrors,
} from '../mock_data/apollo_mock';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const router = createRouter();
localVue.use(VueRouter);
const designToMove = {
__typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
};
describe('Design management index page with Apollo mock', () => {
let wrapper;
let fakeApollo;
let moveDesignHandler;
async function moveDesigns(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns);
localWrapper.find(VueDraggable).vm.$emit('change', {
moved: {
newIndex: 0,
element: designToMove,
},
});
}
const findDesigns = () => wrapper.findAll(Design);
function createComponent({
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
moveDesignHandler = moveHandler;
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
[moveDesignMutation, moveDesignHandler],
];
fakeApollo = createMockApollo(requestHandlers);
wrapper = shallowMount(Index, {
localVue,
apolloProvider: fakeApollo,
router,
stubs: { VueDraggable },
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('has a design with id 1 as a first one', async () => {
createComponent({});
await jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findDesigns()).toHaveLength(3);
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('1');
});
it('calls a mutation with correct parameters and reorders designs', async () => {
createComponent({});
await moveDesigns(wrapper);
expect(moveDesignHandler).toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('2');
});
it('displays flash if mutation had a recoverable error', async () => {
createComponent({
moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
});
await moveDesigns(wrapper);
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem');
});
it('displays flash if mutation had a non-recoverable error', async () => {
createComponent({
moveHandler: jest.fn().mockRejectedValue('Error'),
});
await moveDesigns(wrapper);
await wrapper.vm.$nextTick(); // kick off the DOM update
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong when reordering designs. Please try again',
);
});
});

View File

@ -1,13 +1,15 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import VueApollo, { ApolloMutation } from 'vue-apollo';
import VueDraggable from 'vuedraggable';
import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import Index from '~/design_management/pages/index.vue';
import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
import DeleteButton from '~/design_management/components/delete_button.vue';
import Design from '~/design_management/components/list/item.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
@ -17,6 +19,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import createRouter from '~/design_management/router';
import * as utils from '~/design_management/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants';
import {
designListQueryResponse,
permissionsQueryResponse,
moveDesignMutationResponse,
reorderedDesigns,
moveDesignMutationResponseWithErrors,
} from '../mock_data/apollo_mock';
import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
jest.mock('~/flash.js');
const mockPageEl = {
@ -61,9 +73,21 @@ const mockVersion = {
id: 'gid://gitlab/DesignManagement::Version/1',
};
const designToMove = {
__typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
notesCount: 2,
image: 'image-2',
imageV432x230: 'image-2',
};
describe('Design management index page', () => {
let mutate;
let wrapper;
let fakeApollo;
let moveDesignHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all');
@ -74,6 +98,20 @@ describe('Design management index page', () => {
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 findDesigns = () => wrapper.findAll(Design);
async function moveDesigns(localWrapper) {
await jest.runOnlyPendingTimers();
await localWrapper.vm.$nextTick();
localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns);
localWrapper.find(VueDraggable).vm.$emit('change', {
moved: {
newIndex: 0,
element: designToMove,
},
});
}
function createComponent({
loading = false,
@ -118,8 +156,30 @@ describe('Design management index page', () => {
});
}
function createComponentWithApollo({
moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse),
}) {
localVue.use(VueApollo);
moveDesignHandler = moveHandler;
const requestHandlers = [
[getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)],
[permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
[moveDesignMutation, moveDesignHandler],
];
fakeApollo = createMockApollo(requestHandlers);
wrapper = shallowMount(Index, {
localVue,
apolloProvider: fakeApollo,
router,
stubs: { VueDraggable },
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('designs', () => {
@ -584,4 +644,64 @@ describe('Design management index page', () => {
});
});
});
describe('with mocked Apollo client', () => {
it('has a design with id 1 as a first one', async () => {
createComponentWithApollo({});
await jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(findDesigns()).toHaveLength(3);
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('1');
});
it('calls a mutation with correct parameters and reorders designs', async () => {
createComponentWithApollo({});
await moveDesigns(wrapper);
expect(moveDesignHandler).toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(
findDesigns()
.at(0)
.props('id'),
).toBe('2');
});
it('displays flash if mutation had a recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors),
});
await moveDesigns(wrapper);
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem');
});
it('displays flash if mutation had a non-recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockRejectedValue('Error'),
});
await moveDesigns(wrapper);
await wrapper.vm.$nextTick(); // kick off the DOM update
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong when reordering designs. Please try again',
);
});
});
});

View File

@ -45,7 +45,7 @@ RSpec.describe Gitlab::Auth::OAuth::Provider do
end
end
describe '#config_for' do
describe '.config_for' do
context 'for an LDAP provider' do
context 'when the provider exists' do
it 'returns the config' do
@ -91,4 +91,46 @@ RSpec.describe Gitlab::Auth::OAuth::Provider do
end
end
end
describe '.label_for' do
subject { described_class.label_for(name) }
context 'when configuration specifies a custom label' do
let(:name) { 'google_oauth2' }
let(:label) { 'Custom Google Provider' }
let(:provider) { OpenStruct.new({ 'name' => name, 'label' => label }) }
before do
stub_omniauth_setting(providers: [provider])
end
it 'returns the custom label name' do
expect(subject).to eq(label)
end
end
context 'when configuration does not specify a custom label' do
let(:provider) { OpenStruct.new({ 'name' => name } ) }
before do
stub_omniauth_setting(providers: [provider])
end
context 'when the name does not correspond to a label mapping' do
let(:name) { 'twitter' }
it 'returns the titleized name' do
expect(subject).to eq(name.titleize)
end
end
end
context 'when the name corresponds to a label mapping' do
let(:name) { 'gitlab' }
it 'returns the mapped name' do
expect(subject).to eq('GitLab.com')
end
end
end
end

View File

@ -11,6 +11,7 @@ RSpec.describe API::PypiPackages do
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
let_it_be(:job) { create(:ci_build, :running, user: user) }
describe 'GET /api/v4/projects/:id/packages/pypi/simple/:package_name' do
let_it_be(:package) { create(:pypi_package, project: project) }
@ -58,6 +59,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package GET requests'
it_behaves_like 'job token for package GET requests'
it_behaves_like 'rejects PyPI access with unknown project id'
end
@ -108,6 +111,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package uploads'
it_behaves_like 'job token for package uploads'
it_behaves_like 'rejects PyPI access with unknown project id'
end
@ -198,6 +203,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package uploads'
it_behaves_like 'job token for package uploads'
it_behaves_like 'rejects PyPI access with unknown project id'
context 'file size above maximum limit' do
@ -273,6 +280,26 @@ RSpec.describe API::PypiPackages do
end
end
context 'with job token headers' do
let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token) }
context 'valid token' do
it_behaves_like 'returning response status', :success
end
context 'invalid token' do
let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar') }
it_behaves_like 'returning response status', :success
end
context 'invalid user' do
let(:headers) { basic_auth_header('foo', job.token) }
it_behaves_like 'returning response status', :success
end
end
it_behaves_like 'rejects PyPI access with unknown project id'
end
end

View File

@ -70,3 +70,59 @@ RSpec.shared_examples 'does not cause n^2 queries' do
end.not_to exceed_query_limit(control)
end
end
RSpec.shared_examples 'job token for package GET requests' do
context 'with job token headers' do
let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token) }
subject { get api(url), headers: headers }
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
project.add_developer(user)
end
context 'valid token' do
it_behaves_like 'returning response status', :success
end
context 'invalid token' do
let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar') }
it_behaves_like 'returning response status', :unauthorized
end
context 'invalid user' do
let(:headers) { basic_auth_header('foo', job.token) }
it_behaves_like 'returning response status', :unauthorized
end
end
end
RSpec.shared_examples 'job token for package uploads' do
context 'with job token headers' do
let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, job.token).merge(workhorse_header) }
before do
project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
project.add_developer(user)
end
context 'valid token' do
it_behaves_like 'returning response status', :success
end
context 'invalid token' do
let(:headers) { basic_auth_header(::Gitlab::Auth::CI_JOB_USER, 'bar').merge(workhorse_header) }
it_behaves_like 'returning response status', :unauthorized
end
context 'invalid user' do
let(:headers) { basic_auth_header('foo', job.token).merge(workhorse_header) }
it_behaves_like 'returning response status', :unauthorized
end
end
end