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, atVersion: this.designsVersion,
}; };
}, },
isDiscussionHighlighted() { isDiscussionActive() {
return this.discussion.notes[0].id === this.activeDiscussion.id; return this.discussion.notes.some(({ id }) => id === this.activeDiscussion.id);
}, },
resolveCheckboxText() { resolveCheckboxText() {
return this.discussion.resolved return this.discussion.resolved
@ -134,18 +134,6 @@ export default {
isFormVisible() { isFormVisible() {
return this.isFormRendered && this.discussionWithOpenForm === this.discussion.id; 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: { methods: {
addDiscussionComment( addDiscussionComment(
@ -199,6 +187,14 @@ export default {
this.isResolving = false; 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, createNoteMutation,
}; };
@ -221,7 +217,7 @@ export default {
:note="firstNote" :note="firstNote"
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving" :is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" :class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('updateNoteError', $event)" @error="$emit('updateNoteError', $event)"
> >
<template v-if="discussion.resolvable" #resolveDiscussion> <template v-if="discussion.resolvable" #resolveDiscussion>
@ -265,7 +261,7 @@ export default {
:note="note" :note="note"
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving" :is-resolving="isResolving"
:class="{ 'gl-bg-blue-50': isDiscussionHighlighted }" :class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('updateNoteError', $event)" @error="$emit('updateNoteError', $event)"
/> />
<li v-show="isReplyPlaceholderVisible" class="reply-wrapper"> <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 TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DesignReplyForm from './design_reply_form.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'; import { hasErrors } from '../../utils/cache_update';
export default { export default {
@ -47,7 +47,7 @@ export default {
return findNoteId(this.note.id); return findNoteId(this.note.id);
}, },
isNoteLinked() { isNoteLinked() {
return this.$route.hash === `#note_${this.noteAnchorId}`; return extractDesignNoteId(this.$route.hash) === this.noteAnchorId;
}, },
mutationPayload() { mutationPayload() {
return { return {
@ -59,13 +59,6 @@ export default {
return !this.isEditing && this.note.userPermissions.adminNote; return !this.isEditing && this.note.userPermissions.adminNote;
}, },
}, },
mounted() {
this.$nextTick(() => {
if (this.isNoteLinked) {
this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
}
});
},
methods: { methods: {
hideForm() { hideForm() {
this.isEditing = false; this.isEditing = false;

View File

@ -237,7 +237,12 @@ export default {
}); });
}, },
isNoteInactive(note) { 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) { designPinClass(note) {
return { inactive: this.isNoteInactive(note), resolved: note.resolved }; return { inactive: this.isNoteInactive(note), resolved: note.resolved };

View File

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

View File

@ -11,11 +11,11 @@
- if auth_active?(provider) - if auth_active?(provider)
- if unlink_allowed - if unlink_allowed
= link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do = 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 - else
%a.provider-btn %a.provider-btn
= s_('Profiles|Active') = s_('Profiles|%{provider} Active') % { provider: label_for_provider(provider) }
- elsif link_allowed - elsif link_allowed
= link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn gl-text-blue-500' do = 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 = 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, updated_at timestamp with time zone NOT NULL,
merge_request_id integer NOT NULL, merge_request_id integer NOT NULL,
approvals_required smallint DEFAULT 0 NOT NULL, approvals_required smallint DEFAULT 0 NOT NULL,
code_owner boolean DEFAULT false NOT NULL,
name character varying NOT NULL, name character varying NOT NULL,
rule_type smallint DEFAULT 1 NOT NULL, rule_type smallint DEFAULT 1 NOT NULL,
report_type smallint, 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 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 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); 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_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 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); 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 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). - It [uploads files to S3 with proper `Content-MD5` headers](https://gitlab.com/gitlab-org/gitlab-workhorse/-/issues/222).
NOTE: **Note:** Because [direct upload mode](../development/uploads.md#direct-upload)
Only AWS S3-compatible providers and Google are must be enabled, only the following providers can be used:
supported at the moment since [direct upload
mode](../development/uploads.md#direct-upload) must be used. Background - [Amazon S3-compatible providers](#s3-compatible-connection-settings)
upload is not supported in this mode. We recommend direct upload mode because - [Google Cloud Storage](#google-cloud-storage-gcs)
it does not require a shared folder, and [this setting may become the default](https://gitlab.com/gitlab-org/gitlab/-/issues/27331). - [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:** NOTE: **Note:**
Consolidated object storage configuration cannot be used for 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 | | `title` | string | yes | The title of the epic |
| `labels` | string | no | The comma separated list of labels | | `labels` | string | no | The comma separated list of labels |
| `description` | string | no | The description of the epic. Limited to 1,048,576 characters. | | `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_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) | | `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) | | `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 | | `epic_iid` | integer/string | yes | The internal ID of the epic |
| `title` | string | no | The title of an epic | | `title` | string | no | The title of an epic |
| `description` | string | no | The description of an epic. Limited to 1,048,576 characters. | | `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 | | `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_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) | | `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 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 confidential: Boolean
@ -16809,7 +16809,7 @@ input UpdateEpicInput {
clientMutationId: String 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 confidential: Boolean

View File

@ -7089,7 +7089,7 @@
}, },
{ {
"name": "confidential", "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": { "type": {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Boolean", "name": "Boolean",
@ -49522,7 +49522,7 @@
}, },
{ {
"name": "confidential", "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": { "type": {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Boolean", "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 - **In an existing epic:** in the epic's sidebar, select **Edit** next to **Confidentiality** then
select **Turn on**. 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 ## Manage issues assigned to an epic
### Add a new issue 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 ## Use GitLab CI/CD to build packages
You can use [GitLab CI/CD](../../../ci/README.md) 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`. 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). 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 If you use CI/CD to build a package, extended activity
information is displayed when you view the package details: information is displayed when you view the package details:
![Package CI/CD activity](img/package_activity_v12_10.png) ![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. user who triggered it.
## Download a package ## Download a package

View File

@ -302,20 +302,10 @@ Successfully installed mypypipackage-0.0.1
## Using GitLab CI with PyPI packages ## Using GitLab CI with PyPI packages
NOTE: **Note:** > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/11678) in GitLab 13.4.
`CI_JOB_TOKEN`s are not yet supported for use with PyPI.
To work with PyPI commands within [GitLab CI/CD](./../../../ci/README.md), you can use To work with PyPI commands within [GitLab CI/CD](./../../../ci/README.md), you can use
[environment variables](./../../../ci/variables/README.md#custom-environment-variables) `CI_JOB_TOKEN` in place of the personal access token or deploy token in your commands.
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.
For example: For example:
@ -326,5 +316,18 @@ run:
script: script:
- pip install twine - pip install twine
- python setup.py sdist bdist_wheel - 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' requires :sha256, type: String, desc: 'The PyPi package sha256 check sum'
end 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 get 'files/:sha256/*file_identifier' do
project = unauthorized_user_project! project = unauthorized_user_project!
@ -87,7 +87,7 @@ module API
# An Api entry point but returns an HTML file instead of JSON. # An Api entry point but returns an HTML file instead of JSON.
# PyPi simple API returns the package descriptor as a simple HTML file. # 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 get 'simple/*package_name', format: :txt do
authorize_read_package!(authorized_user_project) authorize_read_package!(authorized_user_project)
@ -117,7 +117,7 @@ module API
optional :sha256_digest, type: String optional :sha256_digest, type: String
end 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 post do
authorize_upload!(authorized_user_project) 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) 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! forbidden!
end 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 post 'authorize' do
authorize_workhorse!( authorize_workhorse!(
subject: authorized_user_project, 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." 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 "" msgstr ""
msgid "Profiles|%{provider} Active"
msgstr ""
msgid "Profiles|@username" msgid "Profiles|@username"
msgstr "" msgstr ""
@ -18834,7 +18837,7 @@ msgstr ""
msgid "Profiles|Commit email" msgid "Profiles|Commit email"
msgstr "" msgstr ""
msgid "Profiles|Connect" msgid "Profiles|Connect %{provider}"
msgstr "" msgstr ""
msgid "Profiles|Connected Accounts" msgid "Profiles|Connected Accounts"
@ -18858,6 +18861,9 @@ msgstr ""
msgid "Profiles|Disconnect" msgid "Profiles|Disconnect"
msgstr "" msgstr ""
msgid "Profiles|Disconnect %{provider}"
msgstr ""
msgid "Profiles|Do not show on profile" msgid "Profiles|Do not show on profile"
msgstr "" msgstr ""

View File

@ -22,7 +22,12 @@ RSpec.describe 'Merge request > User assigns themselves' do
end end
it 'updates updated_by', :js do 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 end
it 'returns user to the merge request', :js do it 'returns user to the merge request', :js do

View File

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

View File

@ -86,16 +86,6 @@ describe('Design note component', () => {
expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); 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', () => { it('should not render edit icon when user does not have a permission', () => {
createComponent({ createComponent({
note, note,

View File

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

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 { createLocalVue, shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo'; import VueApollo, { ApolloMutation } from 'vue-apollo';
import VueDraggable from 'vuedraggable'; import VueDraggable from 'vuedraggable';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import Index from '~/design_management/pages/index.vue'; import Index from '~/design_management/pages/index.vue';
import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql'; import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
import DeleteButton from '~/design_management/components/delete_button.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 { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
import { import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
@ -17,6 +19,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import createRouter from '~/design_management/router'; import createRouter from '~/design_management/router';
import * as utils from '~/design_management/utils/design_management_utils'; import * as utils from '~/design_management/utils/design_management_utils';
import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; 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'); jest.mock('~/flash.js');
const mockPageEl = { const mockPageEl = {
@ -61,9 +73,21 @@ const mockVersion = {
id: 'gid://gitlab/DesignManagement::Version/1', 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', () => { describe('Design management index page', () => {
let mutate; let mutate;
let wrapper; let wrapper;
let fakeApollo;
let moveDesignHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
const findSelectAllButton = () => wrapper.find('.js-select-all'); 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 findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]');
const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]'); 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({ function createComponent({
loading = false, 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(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('designs', () => { 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
end end
describe '#config_for' do describe '.config_for' do
context 'for an LDAP provider' do context 'for an LDAP provider' do
context 'when the provider exists' do context 'when the provider exists' do
it 'returns the config' do it 'returns the config' do
@ -91,4 +91,46 @@ RSpec.describe Gitlab::Auth::OAuth::Provider do
end end
end 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 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(: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(: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(: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 describe 'GET /api/v4/projects/:id/packages/pypi/simple/:package_name' do
let_it_be(:package) { create(:pypi_package, project: project) } 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 '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' it_behaves_like 'rejects PyPI access with unknown project id'
end end
@ -108,6 +111,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package uploads' 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' it_behaves_like 'rejects PyPI access with unknown project id'
end end
@ -198,6 +203,8 @@ RSpec.describe API::PypiPackages do
it_behaves_like 'deploy token for package uploads' 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' it_behaves_like 'rejects PyPI access with unknown project id'
context 'file size above maximum limit' do context 'file size above maximum limit' do
@ -273,6 +280,26 @@ RSpec.describe API::PypiPackages do
end end
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' it_behaves_like 'rejects PyPI access with unknown project id'
end end
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.not_to exceed_query_limit(control)
end end
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