diff options
33 files changed, 569 insertions, 305 deletions
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index cd2545b48de..d28635db601 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -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"> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index eab19e38a45..6c380153a3f 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -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; diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 3f214ff54b4..5c4a3ab5f94 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -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 }; diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql index 26edd2c0be1..28224671326 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_note.fragment.graphql @@ -25,5 +25,10 @@ fragment DesignNote on Note { } discussion { id + notes { + nodes { + id + } + } } } diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml index a87191d0fa4..f7368c5e921 100644 --- a/app/views/profiles/accounts/_providers.html.haml +++ b/app/views/profiles/accounts/_providers.html.haml @@ -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 diff --git a/changelogs/unreleased/202012-pypi-job-tokens.yml b/changelogs/unreleased/202012-pypi-job-tokens.yml new file mode 100644 index 00000000000..9b0138faaa1 --- /dev/null +++ b/changelogs/unreleased/202012-pypi-job-tokens.yml @@ -0,0 +1,5 @@ +--- +title: Add job token authentication for the GitLab PyPI package repository +merge_request: 40888 +author: +type: added diff --git a/changelogs/unreleased/230835-drop-code_owner-column-from-approvalmergerequestrule.yml b/changelogs/unreleased/230835-drop-code_owner-column-from-approvalmergerequestrule.yml new file mode 100644 index 00000000000..81a96054c54 --- /dev/null +++ b/changelogs/unreleased/230835-drop-code_owner-column-from-approvalmergerequestrule.yml @@ -0,0 +1,5 @@ +--- +title: Drop code_owner column from approval_merge_request_rules +merge_request: 40322 +author: +type: other diff --git a/changelogs/unreleased/dblessing-social-connect-text.yml b/changelogs/unreleased/dblessing-social-connect-text.yml new file mode 100644 index 00000000000..05da9761cf6 --- /dev/null +++ b/changelogs/unreleased/dblessing-social-connect-text.yml @@ -0,0 +1,5 @@ +--- +title: Display provider name for profile social sign-in connectors +merge_request: 41198 +author: +type: changed diff --git a/changelogs/unreleased/fix-scroll-to-note-designs.yml b/changelogs/unreleased/fix-scroll-to-note-designs.yml new file mode 100644 index 00000000000..611c9654cce --- /dev/null +++ b/changelogs/unreleased/fix-scroll-to-note-designs.yml @@ -0,0 +1,5 @@ +--- +title: Highlight design discussion if any comment in discussion is linked +merge_request: 41062 +author: +type: fixed diff --git a/db/post_migrate/20200901212304_drop_code_owner_column_from_approval_merge_request_rule.rb b/db/post_migrate/20200901212304_drop_code_owner_column_from_approval_merge_request_rule.rb new file mode 100644 index 00000000000..7524ae8e15b --- /dev/null +++ b/db/post_migrate/20200901212304_drop_code_owner_column_from_approval_merge_request_rule.rb @@ -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 diff --git a/db/schema_migrations/20200901212304 b/db/schema_migrations/20200901212304 new file mode 100644 index 00000000000..3dcc9cdd8f0 --- /dev/null +++ b/db/schema_migrations/20200901212304 @@ -0,0 +1 @@ +6fb93002ffd5c1d1bfff5bea8a99cbbfc7cefefbc450a9d067ee0cfab8d11e9e
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 826ffe6e9f5..0539f45bb2b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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); diff --git a/doc/administration/object_storage.md b/doc/administration/object_storage.md index e0ae532dfb2..024e71a89ae 100644 --- a/doc/administration/object_storage.md +++ b/doc/administration/object_storage.md @@ -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 diff --git a/doc/api/epics.md b/doc/api/epics.md index 45bf406dec2..c3ba42c6efd 100644 --- a/doc/api/epics.md +++ b/doc/api/epics.md @@ -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) | diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index ca3c71761a2..0fb210cf112 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -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 diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index fd545d4cbaa..01a6b0307a2 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -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", diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 3dfa6a33255..c09032bffb2 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -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 diff --git a/doc/user/packages/package_registry/index.md b/doc/user/packages/package_registry/index.md index f7ee1a4808e..4f205013418 100644 --- a/doc/user/packages/package_registry/index.md +++ b/doc/user/packages/package_registry/index.md @@ -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 diff --git a/doc/user/packages/pypi_repository/index.md b/doc/user/packages/pypi_repository/index.md index 8e3a5f6eb5f..2b30fd9209a 100644 --- a/doc/user/packages/pypi_repository/index.md +++ b/doc/user/packages/pypi_repository/index.md @@ -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} ``` diff --git a/lib/api/pypi_packages.rb b/lib/api/pypi_packages.rb index b3668a88204..b2528ceae94 100644 --- a/lib/api/pypi_packages.rb +++ b/lib/api/pypi_packages.rb @@ -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, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6910cf8f1a2..895b41a4ca7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/merge_request/user_assigns_themselves_spec.rb b/spec/features/merge_request/user_assigns_themselves_spec.rb index 35446b125ce..04d401683bf 100644 --- a/spec/features/merge_request/user_assigns_themselves_spec.rb +++ b/spec/features/merge_request/user_assigns_themselves_spec.rb @@ -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 diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb index 620c2f60ba3..e8caa2159a4 100644 --- a/spec/features/profiles/account_spec.rb +++ b/spec/features/profiles/account_spec.rb @@ -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}" } diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index d7f136ddbac..b04bfa65e37 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -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, }, }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index a59a2223cf3..c35bb503c96 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -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, diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index bbd0fbee81f..673a09320e5 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -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); }); + }); + + 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(findSecondBadge().classes()).toContain('inactive'); + 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'); + }); }); }); }); diff --git a/spec/frontend/design_management/mock_data/discussion.js b/spec/frontend/design_management/mock_data/discussion.js new file mode 100644 index 00000000000..fbf9a2fdcc1 --- /dev/null +++ b/spec/frontend/design_management/mock_data/discussion.js @@ -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, + }, + ], +}; diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js index 80cb3944786..41cefaca05b 100644 --- a/spec/frontend/design_management/mock_data/notes.js +++ b/spec/frontend/design_management/mock_data/notes.js @@ -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, }, ]; diff --git a/spec/frontend/design_management/pages/index_apollo_spec.js b/spec/frontend/design_management/pages/index_apollo_spec.js deleted file mode 100644 index c1a3f8643e7..00000000000 --- a/spec/frontend/design_management/pages/index_apollo_spec.js +++ /dev/null @@ -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', - ); - }); -}); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 2da02732b1e..661717d29a3 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -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', + ); + }); + }); }); diff --git a/spec/lib/gitlab/auth/o_auth/provider_spec.rb b/spec/lib/gitlab/auth/o_auth/provider_spec.rb index 658a9976cc2..57f17365190 100644 --- a/spec/lib/gitlab/auth/o_auth/provider_spec.rb +++ b/spec/lib/gitlab/auth/o_auth/provider_spec.rb @@ -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 diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb index 0ac1d96bb3e..85a107ee804 100644 --- a/spec/requests/api/pypi_packages_spec.rb +++ b/spec/requests/api/pypi_packages_spec.rb @@ -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 diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb index 4bcff505008..c9a33701161 100644 --- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb @@ -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 |