From c71c2ba4c29ed3cc483e528a32f34816c98c39f4 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Mon, 23 May 2022 09:08:01 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- Gemfile.lock | 2 +- .../components/access_token_table_app.vue | 204 ++++++++++++++++++ .../components/new_access_token_app.vue | 125 +++++++++++ app/assets/javascripts/access_tokens/index.js | 68 +++++- .../pages/profiles/personal_access_tokens/index.js | 10 +- .../form/input_copy_toggle_visibility.vue | 20 +- app/assets/stylesheets/framework/sidebar.scss | 16 -- app/assets/stylesheets/page_bundles/build.scss | 7 - app/assets/stylesheets/pages/issuable.scss | 4 - app/assets/stylesheets/pages/merge_requests.scss | 9 +- .../profiles/personal_access_tokens_controller.rb | 33 ++- .../projects/google_cloud/base_controller.rb | 1 + .../resolvers/concerns/issue_resolver_arguments.rb | 4 +- app/graphql/resolvers/tree_resolver.rb | 2 +- app/models/clusters/applications/runner.rb | 2 +- .../personal_access_tokens/index.html.haml | 8 +- .../_merge_request_merge_method_settings.html.haml | 58 +++--- app/views/shared/access_tokens/_form.html.haml | 3 +- .../development/access_token_ajax.yml | 8 + doc/architecture/blueprints/ci_scale/index.md | 6 +- doc/user/group/saml_sso/index.md | 2 +- .../entities/personal_access_token_with_details.rb | 13 ++ locale/gitlab.pot | 29 +++ qa/qa/page/component/access_tokens.rb | 12 ++ qa/qa/tools/delete_test_snippets.rb | 7 +- .../personal_access_tokens_controller_spec.rb | 65 ++++-- .../profiles/personal_access_tokens_spec.rb | 159 +++++++++++--- .../projects/settings/access_tokens_spec.rb | 2 +- .../components/access_token_table_app_spec.js | 228 +++++++++++++++++++++ .../components/new_access_token_app_spec.js | 161 +++++++++++++++ spec/frontend/access_tokens/index_spec.js | 214 ++++++++++++++++--- .../form/input_copy_toggle_visibility_spec.js | 28 +++ .../design_management/versions_resolver_spec.rb | 2 + .../personal_access_token_with_details_spec.rb | 29 +++ spec/lib/gitlab/graphql/markdown_field_spec.rb | 4 +- .../helpers/countries_controller_test_helper.rb | 9 + spec/support/helpers/gitaly_setup.rb | 10 +- .../features/access_tokens_shared_examples.rb | 2 +- 38 files changed, 1397 insertions(+), 169 deletions(-) create mode 100644 app/assets/javascripts/access_tokens/components/access_token_table_app.vue create mode 100644 app/assets/javascripts/access_tokens/components/new_access_token_app.vue create mode 100644 config/feature_flags/development/access_token_ajax.yml create mode 100644 lib/api/entities/personal_access_token_with_details.rb create mode 100644 spec/frontend/access_tokens/components/access_token_table_app_spec.js create mode 100644 spec/frontend/access_tokens/components/new_access_token_app_spec.js create mode 100644 spec/lib/api/entities/personal_access_token_with_details_spec.rb create mode 100644 spec/support/helpers/countries_controller_test_helper.rb diff --git a/Gemfile.lock b/Gemfile.lock index fbfb49aa8ee..7151b09bf01 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -916,7 +916,7 @@ GEM orm_adapter (0.5.0) os (1.1.1) parallel (1.20.1) - parser (3.0.3.2) + parser (3.1.2.0) ast (~> 2.4.1) parslet (1.8.2) pastel (0.8.0) diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue new file mode 100644 index 00000000000..e936ad8aa14 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue @@ -0,0 +1,204 @@ + + + diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue new file mode 100644 index 00000000000..69a4fedabae --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue @@ -0,0 +1,125 @@ + + + diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index fb5c5521ce9..a7a03523e7f 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -3,12 +3,57 @@ import Vue from 'vue'; import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { parseRailsFormFields } from '~/lib/utils/forms'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import AccessTokenTableApp from './components/access_token_table_app.vue'; import ExpiresAtField from './components/expires_at_field.vue'; +import NewAccessTokenApp from './components/new_access_token_app.vue'; import TokensApp from './components/tokens_app.vue'; import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants'; +export const initAccessTokenTableApp = () => { + const el = document.querySelector('#js-access-token-table-app'); + + if (!el) { + return null; + } + + const { + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens: initialActiveAccessTokensJson, + noActiveTokensMessage: noTokensMessage, + } = el.dataset; + + // Default values + const noActiveTokensMessage = + noTokensMessage || + sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }); + const showRole = 'showRole' in el.dataset; + + const initialActiveAccessTokens = convertObjectPropsToCamelCase( + JSON.parse(initialActiveAccessTokensJson), + { + deep: true, + }, + ); + + return new Vue({ + el, + name: 'AccessTokenTableRoot', + provide: { + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + noActiveTokensMessage, + showRole, + }, + render(h) { + return h(AccessTokenTableApp); + }, + }); +}; + export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); @@ -33,6 +78,27 @@ export const initExpiresAtField = () => { }); }; +export const initNewAccessTokenApp = () => { + const el = document.querySelector('#js-new-access-token-app'); + + if (!el) { + return null; + } + + const { accessTokenType } = el.dataset; + + return new Vue({ + el, + name: 'NewAccessTokenRoot', + provide: { + accessTokenType, + }, + render(h) { + return h(NewAccessTokenApp); + }, + }); +}; + export const initProjectsField = () => { const el = document.querySelector('.js-access-tokens-projects'); diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index 37e9b7e99d4..3fae9809e51 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -1,5 +1,13 @@ -import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens'; +import { + initAccessTokenTableApp, + initExpiresAtField, + initNewAccessTokenApp, + initProjectsField, + initTokensApp, +} from '~/access_tokens'; +initAccessTokenTableApp(); initExpiresAtField(); +initNewAccessTokenApp(); initProjectsField(); initTokensApp(); diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index 69548f0e7a8..11fcc697602 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -52,6 +52,20 @@ export default { return {}; }, }, + /* + `inputClass` prop should be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/357848 + is implemented. + */ + inputClass: { + type: String, + required: false, + default: '', + }, + qaSelector: { + type: String, + required: false, + default: undefined, + }, }, data() { return { @@ -73,6 +87,9 @@ export default { displayedValue() { return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20); }, + classInput() { + return `gl-font-monospace! gl-cursor-default! ${this.inputClass}`.trimEnd(); + }, }, methods: { handleToggleVisibilityButtonClick() { @@ -98,7 +115,8 @@ export default { (negated_args, ctx) { negated_args.to_h }, required: false argument :crm_contact_id, GraphQL::Types::String, required: false, @@ -85,6 +84,7 @@ module IssueResolverArguments # Will need to be made group & namespace aware with # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 + args[:not] = args[:not].to_h if args[:not].present? args[:iids] ||= [args.delete(:iid)].compact if args[:iid] args[:attempt_project_search_optimizations] = true if args[:search].present? @@ -98,6 +98,8 @@ module IssueResolverArguments end def ready?(**args) + args[:not] = args[:not].to_h if args[:not].present? + params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args) params_not_mutually_exclusive(args, mutually_exclusive_milestone_args) params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args) diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb index f02eb226810..553f9aa6cd9 100644 --- a/app/graphql/resolvers/tree_resolver.rb +++ b/app/graphql/resolvers/tree_resolver.rb @@ -16,7 +16,6 @@ module Resolvers description: 'Used to get a recursive tree. Default is false.' argument :ref, GraphQL::Types::String, required: false, - default_value: :head, description: 'Commit ref to get the tree for. Default value is HEAD.' alias_method :repository, :object @@ -24,6 +23,7 @@ module Resolvers def resolve(**args) return unless repository.exists? + args[:ref] ||= :head repository.tree(args[:ref], args[:path], recursive: args[:recursive]) end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index e62b6fa5fc5..bed0eab5a58 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.39.0' + VERSION = '0.41.0' self.table_name = 'clusters_applications_runners' diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 887d07f7a20..5be862257b4 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -1,3 +1,4 @@ +- ajax = Feature.enabled?(:access_token_ajax) - breadcrumb_title s_('AccessTokens|Access Tokens') - page_title s_('AccessTokens|Personal Access Tokens') - type = _('personal access token') @@ -15,19 +16,24 @@ = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.') .col-lg-8 + #js-new-access-token-app{ data: { access_token_type: type } } - if @new_personal_access_token = render 'shared/access_tokens/created_container', type: type, new_token_value: @new_personal_access_token = render 'shared/access_tokens/form', + ajax: ajax, type: type, path: profile_personal_access_tokens_path, token: @personal_access_token, scopes: @scopes, help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') - = render 'shared/access_tokens/table', + - if ajax + #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_personal_access_tokens.to_json } } + - else + = render 'shared/access_tokens/table', type: type, type_plural: type_plural, active_tokens: @active_personal_access_tokens, diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml index cb660750632..f205fe2b9bf 100644 --- a/app/views/projects/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/_merge_request_merge_method_settings.html.haml @@ -1,38 +1,34 @@ - form = local_assigns.fetch(:form) +- labelMerge = s_('ProjectSettings|Merge commit') +- everyMergeCommit = s_('ProjectSettings|Every merge creates a merge commit.') + +- labelRebase = s_('ProjectSettings|Merge commit with semi-linear history') +- rebaseUpToDate = s_('ProjectSettings|Merging is only allowed when the source branch is up-to-date with its target.') +- rebaseSemiLinear = s_('ProjectSettings|When semi-linear merge is not possible, the user is given the option to rebase.') + +- labelFastForward = s_('ProjectSettings|Fast-forward merge') +- noMergeCommit = s_('ProjectSettings|No merge commits are created.') +- ffOnly = s_('ProjectSettings|Fast-forward merges only.') +- ffConflictRebase = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.') +- ffTrains = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.') +- ffTrainsHelp = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer' + .form-group %b= s_('ProjectSettings|Merge method') %p.text-secondary = s_('ProjectSettings|Determine what happens to the commit history when you merge a merge request.') = link_to s_('ProjectSettings|How do they differ?'), help_page_path('user/project/merge_requests/methods/index.md'), target: '_blank', rel: 'noopener noreferrer' - .form-check.mb-2 - = form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input" - = label_tag :project_merge_method_merge, class: 'form-check-label' do - = s_('ProjectSettings|Merge commit') - .text-secondary - = s_('ProjectSettings|Every merge creates a merge commit.') - - .form-check.mb-2 - = form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input" - = label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do - = s_('ProjectSettings|Merge commit with semi-linear history') - .text-secondary - = s_('ProjectSettings|Every merge creates a merge commit.') - %br - = s_('ProjectSettings|Merging is only allowed when the source branch is up-to-date with its target.') - %br - = s_('ProjectSettings|When semi-linear merge is not possible, the user is given the option to rebase.') - - .form-check.mb-2 - = form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", data: { qa_selector: 'merge_ff_radio' } - = label_tag :project_merge_method_ff, class: 'form-check-label' do - = s_('ProjectSettings|Fast-forward merge') - .text-secondary - = s_('ProjectSettings|No merge commits are created.') - %br - = s_('ProjectSettings|Fast-forward merges only.') - %br - = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.') - %div - = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.') - = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer' + = form.gitlab_ui_radio_component :merge_method, + :merge, + labelMerge, + help_text: everyMergeCommit + = form.gitlab_ui_radio_component :merge_method, + :rebase_merge, + labelRebase, + help_text: (everyMergeCommit + "
" + rebaseUpToDate + "
" + rebaseSemiLinear).html_safe + = form.gitlab_ui_radio_component :merge_method, + :ff, + labelFastForward, + help_text: (noMergeCommit + "
" + ffOnly + "
" + ffConflictRebase + "
" + ffTrains + " " + ffTrainsHelp).html_safe, + radio_options: { data: { qa_selector: 'merge_ff_radio' } } diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index d4106ba4e5d..665b7794b79 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -1,3 +1,4 @@ +- ajax = local_assigns.fetch(:ajax, false) - title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type }) - prefix = local_assigns.fetch(:prefix, :personal_access_token) - help_path = local_assigns.fetch(:help_path) @@ -10,7 +11,7 @@ %p.profile-settings-content = _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type } -= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f| += gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f| = form_errors(token) diff --git a/config/feature_flags/development/access_token_ajax.yml b/config/feature_flags/development/access_token_ajax.yml new file mode 100644 index 00000000000..ea7c2f7a083 --- /dev/null +++ b/config/feature_flags/development/access_token_ajax.yml @@ -0,0 +1,8 @@ +--- +name: access_token_ajax +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84373 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/359956 +milestone: '15.0' +type: development +group: group::authentication and authorization +default_enabled: false diff --git a/doc/architecture/blueprints/ci_scale/index.md b/doc/architecture/blueprints/ci_scale/index.md index be21f824392..1c3aee2f860 100644 --- a/doc/architecture/blueprints/ci_scale/index.md +++ b/doc/architecture/blueprints/ci_scale/index.md @@ -37,7 +37,7 @@ to sustain future growth. ### We are running out of the capacity to store primary keys The primary key in `ci_builds` table is an integer generated in a sequence. -Historically, Rails used to use [integer](https://www.postgresql.org/docs/9.1/datatype-numeric.html) +Historically, Rails used to use [integer](https://www.postgresql.org/docs/14/datatype-numeric.html) type when creating primary keys for a table. We did use the default when we [created the `ci_builds` table in 2012](https://gitlab.com/gitlab-org/gitlab/-/blob/046b28312704f3131e72dcd2dbdacc5264d4aa62/db/ci/migrate/20121004165038_create_builds.rb). [The behavior of Rails has changed](https://github.com/rails/rails/pull/26266) @@ -55,8 +55,8 @@ that have the same problem. Primary keys problem will be tackled by our Database Team. -**Status**: As of October 2021 the primary keys in CI tables have been migrated -to big integers. +**Status**: In October 2021, the primary keys in CI tables were migrated +to big integers. See the [related Epic](https://gitlab.com/groups/gitlab-org/-/epics/5657) for more details. ### The table is too large diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index c61818efe61..7c784b0d50e 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -201,7 +201,7 @@ For NameID, the following settings are recommended: - **NameID** set to `Basic Information > Primary email`. When selecting **Verify SAML Configuration** on the GitLab SAML SSO page, disregard the warning about the NameID format -"persistent" recommended. +"persistent" being recommended. See the [troubleshooting page](../../../administration/troubleshooting/group_saml_scim.md#google-workspace) for an example configuration. diff --git a/lib/api/entities/personal_access_token_with_details.rb b/lib/api/entities/personal_access_token_with_details.rb new file mode 100644 index 00000000000..5654bd4a1e1 --- /dev/null +++ b/lib/api/entities/personal_access_token_with_details.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module API + module Entities + class PersonalAccessTokenWithDetails < Entities::PersonalAccessToken + expose :expired?, as: :expired + expose :expires_soon?, as: :expires_soon + expose :revoke_path do |token| + Gitlab::Routing.url_helpers.revoke_profile_personal_access_token_path(token) + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 65db4ed450f..557f6cb43c3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1929,6 +1929,9 @@ msgstr "" msgid "AccessTokens|Static object token" msgstr "" +msgid "AccessTokens|The last time a token was used" +msgstr "" + msgid "AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled." msgstr "" @@ -2016,6 +2019,9 @@ msgstr "" msgid "Active" msgstr "" +msgid "Active %{accessTokenTypePlural} (%{totalAccessTokens})" +msgstr "" + msgid "Active %{type} (%{token_length})" msgstr "" @@ -4926,6 +4932,9 @@ msgstr "" msgid "Are you sure you want to retry this migration?" msgstr "" +msgid "Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone." +msgstr "" + msgid "Are you sure you want to revoke this %{type}? This action cannot be undone." msgstr "" @@ -10039,6 +10048,9 @@ msgstr "" msgid "Copy" msgstr "" +msgid "Copy %{accessTokenType}" +msgstr "" + msgid "Copy %{http_label} clone URL" msgstr "" @@ -37848,6 +37860,11 @@ msgstr[1] "" msgid "The fork relationship has been removed." msgstr "" +msgid "The form contains the following error:" +msgid_plural "The form contains the following errors:" +msgstr[0] "" +msgstr[1] "" + msgid "The form contains the following warning:" msgstr "" @@ -39084,6 +39101,9 @@ msgstr "" msgid "This user has an unconfirmed email address. You may force a confirmation." msgstr "" +msgid "This user has no active %{accessTokenTypePlural}." +msgstr "" + msgid "This user has no active %{type}." msgstr "" @@ -39766,6 +39786,9 @@ msgstr "" msgid "Token name" msgstr "" +msgid "Token valid until revoked" +msgstr "" + msgid "Tokens|Scopes set the permission levels granted to the token." msgstr "" @@ -43969,6 +43992,12 @@ msgstr "" msgid "Your name" msgstr "" +msgid "Your new %{accessTokenType}" +msgstr "" + +msgid "Your new %{accessTokenType} has been created." +msgstr "" + msgid "Your new %{type}" msgstr "" diff --git a/qa/qa/page/component/access_tokens.rb b/qa/qa/page/component/access_tokens.rb index 6a9249621e1..ec8bbe8ff97 100644 --- a/qa/qa/page/component/access_tokens.rb +++ b/qa/qa/page/component/access_tokens.rb @@ -22,10 +22,22 @@ module QA element :api_label, '#{scope}_label' # rubocop:disable QA/ElementWithPattern, Lint/InterpolationCheck end + base.view 'app/assets/javascripts/access_tokens/components/new_access_token_app.vue' do + element :created_access_token + end + + # This element will be removed once `access_token_ajax` feature flag is removed + # and this work is completed: https://gitlab.com/gitlab-org/gitlab/-/issues/357848 base.view 'app/views/shared/access_tokens/_created_container.html.haml' do element :created_access_token end + base.view 'app/assets/javascripts/access_tokens/components/access_token_table_app.vue' do + element :revoke_button + end + + # This element will be removed once `access_token_ajax` feature flag is removed + # and this work is completed: https://gitlab.com/gitlab-org/gitlab/-/issues/357848 base.view 'app/views/shared/access_tokens/_table.html.haml' do element :revoke_button end diff --git a/qa/qa/tools/delete_test_snippets.rb b/qa/qa/tools/delete_test_snippets.rb index 5da962b14f3..e590077f81c 100644 --- a/qa/qa/tools/delete_test_snippets.rb +++ b/qa/qa/tools/delete_test_snippets.rb @@ -12,7 +12,7 @@ module QA class DeleteTestSnippets include Support::API - ITEMS_PER_PAGE = '100' + ITEMS_PER_PAGE = '1' def initialize(delete_before: (Date.today - 1).to_s, dry_run: false) raise ArgumentError, "Please provide GITLAB_ADDRESS" unless ENV['GITLAB_ADDRESS'] @@ -69,6 +69,11 @@ module QA to_delete end snippet_ids.concat(snippets.map { |snippet| snippet['id'] }) + + if (page_no + 1) == 1000 + puts "Stopping at page 1000 to avoid timeout, total number of pages: #{pages}" + break + end end snippet_ids.uniq diff --git a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb index 3859af66292..6b852daeda2 100644 --- a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb +++ b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb @@ -39,30 +39,19 @@ RSpec.describe Profiles::PersonalAccessTokensController do describe '#index' do let!(:active_personal_access_token) { create(:personal_access_token, user: user) } - let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) } - let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) } - let(:token_value) { 's3cr3t' } before do - PersonalAccessToken.redis_store!(user.id, token_value) + # Impersonation and inactive personal tokens are ignored + create(:personal_access_token, :impersonation, user: user) + create(:personal_access_token, :revoked, user: user) get :index end - it "retrieves active personal access tokens" do - expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token) - end - - it "retrieves inactive personal access tokens" do - expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token) - end - - it "does not retrieve impersonation personal access tokens" do - expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token) - expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token) - end + it "only includes details of the active personal access token" do + active_personal_access_tokens_detail = ::API::Entities::PersonalAccessTokenWithDetails + .represent([active_personal_access_token]) - it "retrieves newly created personal access token value" do - expect(assigns(:new_personal_access_token)).to eql(token_value) + expect(assigns(:active_personal_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json) end it "sets PAT name and scopes" do @@ -77,4 +66,44 @@ RSpec.describe Profiles::PersonalAccessTokensController do ) end end + + context 'access_token_ajax feature flag disabled' do + before do + stub_feature_flags(access_token_ajax: false) + PersonalAccessToken.redis_store!(user.id, token_value) + get :index + end + + describe '#index' do + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) } + let(:token_value) { 's3cr3t' } + + it "retrieves active personal access tokens" do + expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token) + end + + it "does not retrieve impersonation tokens or inactive personal access tokens" do + expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token) + expect(assigns(:active_personal_access_tokens)).not_to include(inactive_personal_access_token) + end + + it "retrieves newly created personal access token value" do + expect(assigns(:new_personal_access_token)).to eql(token_value) + end + + it "sets PAT name and scopes" do + name = 'My PAT' + scopes = 'api,read_user' + + get :index, params: { name: name, scopes: scopes } + + expect(assigns(:personal_access_token)).to have_attributes( + name: eq(name), + scopes: contain_exactly(:api, :read_user) + ) + end + end + end end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 8cbc0491441..01e2571ff3e 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -7,28 +7,17 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) } def active_personal_access_tokens - find(".table.active-tokens") - end - - def no_personal_access_tokens_message - find(".settings-message") + find("[data-testid='active-tokens']") end def created_personal_access_token - find("#created-personal-access-token").value + find("[data-testid='new-access-token'] input").value end def feed_token_description "Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs." end - def disallow_personal_access_token_saves! - allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service) - - errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") } - allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors) - end - before do stub_feature_flags(bootstrap_confirmation_modals: false) sign_in(user) @@ -51,6 +40,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do check "read_user" click_on "Create personal access token" + wait_for_all_requests expect(active_personal_access_tokens).to have_text(name) expect(active_personal_access_tokens).to have_text('in') @@ -61,13 +51,16 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do context "when creation fails" do it "displays an error message" do - disallow_personal_access_token_saves! + number_tokens_before = PersonalAccessToken.count visit profile_personal_access_tokens_path fill_in "Token name", with: 'My PAT' - expect { click_on "Create personal access token" }.not_to change { PersonalAccessToken.count } - expect(page).to have_content("Name cannot be nil") - expect(page).not_to have_selector("#created-personal-access-token") + click_on "Create personal access token" + wait_for_all_requests + + expect(number_tokens_before).to equal(PersonalAccessToken.count) + expect(page).to have_content(_("Scopes can't be blank")) + expect(page).not_to have_selector("[data-testid='new-access-tokens']") end end end @@ -103,29 +96,25 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do visit profile_personal_access_tokens_path accept_confirm { click_on "Revoke" } - expect(page).to have_selector(".settings-message") - expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.") + expect(active_personal_access_tokens).to have_text("This user has no active personal access tokens.") end it "removes expired tokens from 'active' section" do personal_access_token.update!(expires_at: 5.days.ago) visit profile_personal_access_tokens_path - expect(page).to have_selector(".settings-message") - expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.") + expect(active_personal_access_tokens).to have_text("This user has no active personal access tokens.") end context "when revocation fails" do it "displays an error message" do - visit profile_personal_access_tokens_path - allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance| allow(instance).to receive(:revocation_permitted?).and_return(false) end + visit profile_personal_access_tokens_path accept_confirm { click_on "Revoke" } expect(active_personal_access_tokens).to have_text(personal_access_token.name) - expect(page).to have_content("Not permitted to revoke") end end end @@ -172,4 +161,126 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do expect(find("#personal_access_token_scopes_api")).to be_checked expect(find("#personal_access_token_scopes_read_user")).to be_checked end + + context 'access_token_ajax feature flag disabled' do + def active_personal_access_tokens + find(".table.active-tokens") + end + + def no_personal_access_tokens_message + find(".settings-message") + end + + def created_personal_access_token + find("#created-personal-access-token").value + end + + def disallow_personal_access_token_saves! + allow_next_instance_of(PersonalAccessToken) do |pat| + pat.errors.add(:name, 'cannot be nil') + end + + allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service) + end + + before do + stub_feature_flags(bootstrap_confirmation_modals: false) + stub_feature_flags(access_token_ajax: false) + sign_in(user) + end + + describe "token creation" do + it "allows creation of a personal access token" do + name = 'My PAT' + + visit profile_personal_access_tokens_path + fill_in "Token name", with: name + + # Set date to 1st of next month + find_field("Expiration date").click + find(".pika-next").click + click_on "1" + + # Scopes + check "read_api" + check "read_user" + + click_on "Create personal access token" + + expect(active_personal_access_tokens).to have_text(name) + expect(active_personal_access_tokens).to have_text('in') + expect(active_personal_access_tokens).to have_text('read_api') + expect(active_personal_access_tokens).to have_text('read_user') + expect(created_personal_access_token).not_to be_empty + end + + context "when creation fails" do + it "displays an error message" do + disallow_personal_access_token_saves! + visit profile_personal_access_tokens_path + fill_in "Token name", with: 'My PAT' + + expect { click_on "Create personal access token" }.not_to change { PersonalAccessToken.count } + expect(page).to have_content("Name cannot be nil") + expect(page).not_to have_selector("#created-personal-access-token") + end + end + end + + describe 'active tokens' do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:personal_access_token) { create(:personal_access_token, user: user) } + + it 'only shows personal access tokens' do + visit profile_personal_access_tokens_path + + expect(active_personal_access_tokens).to have_text(personal_access_token.name) + expect(active_personal_access_tokens).not_to have_text(impersonation_token.name) + end + + context 'when User#time_display_relative is false' do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit profile_personal_access_tokens_path + + expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d')) + end + end + end + + describe "inactive tokens" do + let!(:personal_access_token) { create(:personal_access_token, user: user) } + + it "allows revocation of an active token" do + visit profile_personal_access_tokens_path + accept_confirm { click_on "Revoke" } + + expect(page).to have_selector(".settings-message") + expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.") + end + + it "removes expired tokens from 'active' section" do + personal_access_token.update!(expires_at: 5.days.ago) + visit profile_personal_access_tokens_path + + expect(page).to have_selector(".settings-message") + expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.") + end + + context "when revocation fails" do + it "displays an error message" do + allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance| + allow(instance).to receive(:revocation_permitted?).and_return(false) + end + visit profile_personal_access_tokens_path + + accept_confirm { click_on "Revoke" } + expect(active_personal_access_tokens).to have_text(personal_access_token.name) + end + end + end + end end diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb index 122bf267021..4bc543e080a 100644 --- a/spec/features/projects/settings/access_tokens_spec.rb +++ b/spec/features/projects/settings/access_tokens_spec.rb @@ -48,7 +48,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do it 'shows access token creation form and text' do visit project_settings_access_tokens_path(personal_project) - expect(page).to have_selector('#new_resource_access_token') + expect(page).to have_selector('#js-new-access-token-form') expect(page).to have_text('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.') end end diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js new file mode 100644 index 00000000000..827bc1a6a4d --- /dev/null +++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js @@ -0,0 +1,228 @@ +import { GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; +import { __, s__, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; + +describe('~/access_tokens/components/access_token_table_app', () => { + let wrapper; + + const accessTokenType = 'personal access token'; + const accessTokenTypePlural = 'personal access tokens'; + const initialActiveAccessTokens = []; + const noActiveTokensMessage = 'This user has no active personal access tokens.'; + const showRole = false; + + const defaultActiveAccessTokens = [ + { + name: 'a', + scopes: ['api'], + created_at: '2021-05-01T00:00:00.000Z', + last_used_at: null, + expired: false, + expires_soon: true, + expires_at: null, + revoked: false, + revoke_path: '/-/profile/personal_access_tokens/1/revoke', + role: 'Maintainer', + }, + { + name: 'b', + scopes: ['api', 'sudo'], + created_at: '2022-04-21T00:00:00.000Z', + last_used_at: '2022-04-21T00:00:00.000Z', + expired: true, + expires_soon: false, + expires_at: new Date().toISOString(), + revoked: false, + revoke_path: '/-/profile/personal_access_tokens/2/revoke', + role: 'Maintainer', + }, + ]; + + const createComponent = (props = {}) => { + wrapper = mount(AccessTokenTableApp, { + provide: { + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + noActiveTokensMessage, + showRole, + ...props, + }, + }); + }; + + const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => { + wrapper + .findComponent(DomElementListener) + .vm.$emit('ajax:success', { detail: [{ active_access_tokens: activeAccessTokens }] }); + await nextTick(); + }; + + const findTable = () => wrapper.findComponent(GlTable); + const findHeaders = () => findTable().findAll('th > :first-child'); + const findCells = () => findTable().findAll('td'); + + afterEach(() => { + wrapper?.destroy(); + }); + + it('should render the `GlTable` with default empty message', () => { + createComponent(); + + const cells = findCells(); + expect(cells).toHaveLength(1); + expect(cells.at(0).text()).toBe( + sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }), + ); + }); + + it('should render the `GlTable` with custom empty message', () => { + const noTokensMessage = 'This group has no active access tokens.'; + createComponent({ noActiveTokensMessage: noTokensMessage }); + + const cells = findCells(); + expect(cells).toHaveLength(1); + expect(cells.at(0).text()).toBe(noTokensMessage); + }); + + it('should render an h5 element', () => { + createComponent(); + + expect(wrapper.find('h5').text()).toBe( + sprintf(__('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), { + accessTokenTypePlural, + totalAccessTokens: initialActiveAccessTokens.length, + }), + ); + }); + + it('should render the `GlTable` component with default 6 column headers', () => { + createComponent(); + + const headers = findHeaders(); + expect(headers).toHaveLength(6); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Action'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); + + it('should render the `GlTable` component with 7 headers', () => { + createComponent({ showRole: true }); + + const headers = findHeaders(); + expect(headers).toHaveLength(7); + [ + __('Token name'), + __('Scopes'), + s__('AccessTokens|Created'), + __('Last Used'), + __('Expires'), + __('Role'), + __('Action'), + ].forEach((text, index) => { + expect(headers.at(index).text()).toBe(text); + }); + }); + + it('`Last Used` header should contain a link and an assistive message', () => { + createComponent(); + + const headers = wrapper.findAll('th'); + const lastUsed = headers.at(3); + const anchor = lastUsed.find('a'); + const assistiveElement = lastUsed.find('.gl-sr-only'); + expect(anchor.exists()).toBe(true); + expect(anchor.attributes('href')).toBe( + '/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used', + ); + expect(assistiveElement.text()).toBe(s__('AccessTokens|The last time a token was used')); + }); + + it('updates the table after a success AJAX event', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + expect(cells).toHaveLength(14); + + // First row + expect(cells.at(0).text()).toBe('a'); + expect(cells.at(1).text()).toBe('api'); + expect(cells.at(2).text()).not.toBe(__('Never')); + expect(cells.at(3).text()).toBe(__('Never')); + expect(cells.at(4).text()).toBe(__('Never')); + expect(cells.at(5).text()).toBe('Maintainer'); + let anchor = cells.at(6).find('a'); + expect(anchor.attributes()).toMatchObject({ + 'aria-label': __('Revoke'), + 'data-qa-selector': __('revoke_button'), + href: '/-/profile/personal_access_tokens/1/revoke', + 'data-confirm': sprintf( + __( + 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', + ), + { accessTokenType }, + ), + }); + + expect(anchor.classes()).toContain('btn-danger-secondary'); + + // Second row + expect(cells.at(7).text()).toBe('b'); + expect(cells.at(8).text()).toBe('api, sudo'); + expect(cells.at(9).text()).not.toBe(__('Never')); + expect(cells.at(10).text()).not.toBe(__('Never')); + expect(cells.at(11).text()).toBe(__('Expired')); + expect(cells.at(12).text()).toBe('Maintainer'); + anchor = cells.at(13).find('a'); + expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke'); + expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']); + }); + + it('sorts rows alphabetically', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + + // First and second rows + expect(cells.at(0).text()).toBe('a'); + expect(cells.at(7).text()).toBe('b'); + + const headers = findHeaders(); + await headers.at(0).trigger('click'); + await headers.at(0).trigger('click'); + + // First and second rows have swapped + expect(cells.at(0).text()).toBe('b'); + expect(cells.at(7).text()).toBe('a'); + }); + + it('sorts rows by date', async () => { + createComponent({ showRole: true }); + await triggerSuccess(); + + const cells = findCells(); + + // First and second rows + expect(cells.at(3).text()).toBe('Never'); + expect(cells.at(10).text()).not.toBe('Never'); + + const headers = findHeaders(); + await headers.at(3).trigger('click'); + + // First and second rows have swapped + expect(cells.at(3).text()).not.toBe('Never'); + expect(cells.at(10).text()).toBe('Never'); + }); +}); diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js new file mode 100644 index 00000000000..b750a955fb2 --- /dev/null +++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js @@ -0,0 +1,161 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; +import { createAlert, VARIANT_INFO } from '~/flash'; +import { __, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +jest.mock('~/flash'); + +describe('~/access_tokens/components/new_access_token_app', () => { + let wrapper; + + const accessTokenType = 'personal access token'; + + const createComponent = (provide = { accessTokenType }) => { + wrapper = shallowMount(NewAccessTokenApp, { + provide, + }); + }; + + const triggerSuccess = async (newToken = 'new token') => { + wrapper + .find(DomElementListener) + .vm.$emit('ajax:success', { detail: [{ new_token: newToken }] }); + await nextTick(); + }; + + const triggerError = async (errors = ['1', '2']) => { + wrapper.find(DomElementListener).vm.$emit('ajax:error', { detail: [{ errors }] }); + await nextTick(); + }; + + beforeEach(() => { + // NewAccessTokenApp observes a form element + setHTMLFixture('
'); + + createComponent(); + }); + + afterEach(() => { + resetHTMLFixture(); + wrapper.destroy(); + createAlert.mockClear(); + }); + + it('should render nothing', () => { + expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false); + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + + describe('on success', () => { + it('should render `InputCopyToggleVisibility` component', async () => { + const newToken = '12345'; + await triggerSuccess(newToken); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + + const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility); + expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken); + expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe( + sprintf(__('Copy %{accessTokenType}'), { accessTokenType }), + ); + expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true); + expect(InputCopyToggleVisibilityComponent.props('inputClass')).toBe( + 'qa-created-access-token', + ); + expect(InputCopyToggleVisibilityComponent.props('qaSelector')).toBe( + 'created_access_token_field', + ); + expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe( + sprintf(__('Your new %{accessTokenType}'), { accessTokenType }), + ); + }); + + it('should render an info alert', async () => { + await triggerSuccess(); + + expect(createAlert).toHaveBeenCalledWith({ + message: sprintf(__('Your new %{accessTokenType} has been created.'), { + accessTokenType, + }), + variant: VARIANT_INFO, + }); + }); + + it('should reset the form', async () => { + const resetSpy = jest.spyOn(wrapper.vm.form, 'reset'); + + await triggerSuccess(); + + expect(resetSpy).toHaveBeenCalled(); + }); + }); + + describe('on error', () => { + it('should render an error alert', async () => { + await triggerError(['first', 'second']); + + expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false); + + let GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.props('title')).toBe(__('The form contains the following errors:')); + expect(GlAlertComponent.props('variant')).toBe('danger'); + let itemEls = wrapper.findAll('li'); + expect(itemEls).toHaveLength(2); + expect(itemEls.at(0).text()).toBe('first'); + expect(itemEls.at(1).text()).toBe('second'); + + await triggerError(['one']); + + GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.props('title')).toBe(__('The form contains the following error:')); + expect(GlAlertComponent.props('variant')).toBe('danger'); + itemEls = wrapper.findAll('li'); + expect(itemEls).toHaveLength(1); + }); + + it('the error alert should be dismissible', async () => { + await triggerError(); + + const GlAlertComponent = wrapper.findComponent(GlAlert); + expect(GlAlertComponent.exists()).toBe(true); + + GlAlertComponent.vm.$emit('dismiss'); + await nextTick(); + + expect(wrapper.findComponent(GlAlert).exists()).toBe(false); + }); + }); + + describe('before error or success', () => { + it('should scroll to the container', async () => { + const containerEl = wrapper.vm.$refs.container; + const scrollIntoViewSpy = jest.spyOn(containerEl, 'scrollIntoView'); + + await triggerSuccess(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(false); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + + await triggerError(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith(false); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(2); + }); + + it('should dismiss the info alert', async () => { + const dismissSpy = jest.fn(); + createAlert.mockReturnValue({ dismiss: dismissSpy }); + + await triggerSuccess(); + await triggerError(); + + expect(dismissSpy).toHaveBeenCalled(); + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js index 1d8ac7cec25..b6119f1d167 100644 --- a/spec/frontend/access_tokens/index_spec.js +++ b/spec/frontend/access_tokens/index_spec.js @@ -1,27 +1,118 @@ +/* eslint-disable vue/require-prop-types */ +/* eslint-disable vue/one-component-per-file */ import { createWrapper } from '@vue/test-utils'; import Vue from 'vue'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import { + initAccessTokenTableApp, + initExpiresAtField, + initNewAccessTokenApp, + initProjectsField, + initTokensApp, +} from '~/access_tokens'; +import * as AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; +import * as NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; import * as ProjectsField from '~/access_tokens/components/projects_field.vue'; +import * as TokensApp from '~/access_tokens/components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants'; +import { __, sprintf } from '~/locale'; describe('access tokens', () => { - const FakeComponent = Vue.component('FakeComponent', { - props: { - inputAttrs: { - type: Object, - required: true, - }, - }, - render: () => null, - }); + let wrapper; - beforeEach(() => { - window.gon = { features: { personalAccessTokensScopedToProjects: true } }; + afterEach(() => { + wrapper?.destroy(); + resetHTMLFixture(); }); - afterEach(() => { - document.body.innerHTML = ''; + describe('initAccessTokenTableApp', () => { + const accessTokenType = 'personal access token'; + const accessTokenTypePlural = 'personal access tokens'; + const initialActiveAccessTokens = [{ id: '1' }]; + + const FakeAccessTokenTableApp = Vue.component('FakeComponent', { + inject: [ + 'accessTokenType', + 'accessTokenTypePlural', + 'initialActiveAccessTokens', + 'noActiveTokensMessage', + 'showRole', + ], + props: [ + 'accessTokenType', + 'accessTokenTypePlural', + 'initialActiveAccessTokens', + 'noActiveTokensMessage', + 'showRole', + ], + render: () => null, + }); + AccessTokenTableApp.default = FakeAccessTokenTableApp; + + it('mounts the component and provides required values', () => { + setHTMLFixture( + `
+
`, + ); + + const vueInstance = initAccessTokenTableApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeAccessTokenTableApp); + + expect(component.exists()).toBe(true); + + expect(component.props()).toMatchObject({ + // Required value + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + + // Default values + noActiveTokensMessage: sprintf(__('This user has no active %{accessTokenTypePlural}.'), { + accessTokenTypePlural, + }), + showRole: false, + }); + }); + + it('mounts the component and provides all values', () => { + const noActiveTokensMessage = 'This group has no active access tokens.'; + setHTMLFixture( + `
+
`, + ); + + const vueInstance = initAccessTokenTableApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeAccessTokenTableApp); + + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + accessTokenType, + accessTokenTypePlural, + initialActiveAccessTokens, + noActiveTokensMessage, + showRole: true, + }); + }); + + it('returns `null`', () => { + expect(initNewAccessTokenApp()).toBe(null); + }); }); describe.each` @@ -30,33 +121,42 @@ describe('access tokens', () => { ${initProjectsField} | ${'js-access-tokens-projects'} | ${'projects'} | ${ProjectsField} `('$initFunction', ({ initFunction, mountSelector, fieldName, expectedComponent }) => { describe('when mount element exists', () => { + const FakeComponent = Vue.component('FakeComponent', { + props: ['inputAttrs'], + render: () => null, + }); + const nameAttribute = `access_tokens[${fieldName}]`; const idAttribute = `access_tokens_${fieldName}`; beforeEach(() => { - const mountEl = document.createElement('div'); - mountEl.classList.add(mountSelector); - - const input = document.createElement('input'); - input.setAttribute('name', nameAttribute); - input.setAttribute('data-js-name', fieldName); - input.setAttribute('id', idAttribute); - input.setAttribute('placeholder', 'Foo bar'); - input.setAttribute('value', '1,2'); + window.gon = { features: { personalAccessTokensScopedToProjects: true } }; - mountEl.appendChild(input); - - document.body.appendChild(mountEl); + setHTMLFixture( + `
+ +
`, + ); // Mock component so we don't have to deal with mocking Apollo // eslint-disable-next-line no-param-reassign expectedComponent.default = FakeComponent; }); + afterEach(() => { + delete window.gon; + }); + it('mounts component and sets `inputAttrs` prop', async () => { const vueInstance = await initFunction(); - const wrapper = createWrapper(vueInstance); + wrapper = createWrapper(vueInstance); const component = wrapper.findComponent(FakeComponent); expect(component.exists()).toBe(true); @@ -75,4 +175,64 @@ describe('access tokens', () => { }); }); }); + + describe('initNewAccessTokenApp', () => { + it('mounts the component and sets `accessTokenType` prop', () => { + const accessTokenType = 'personal access token'; + setHTMLFixture( + `
`, + ); + + const FakeNewAccessTokenApp = Vue.component('FakeComponent', { + inject: ['accessTokenType'], + props: ['accessTokenType'], + render: () => null, + }); + NewAccessTokenApp.default = FakeNewAccessTokenApp; + + const vueInstance = initNewAccessTokenApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeNewAccessTokenApp); + + expect(component.exists()).toBe(true); + expect(component.props('accessTokenType')).toEqual(accessTokenType); + }); + + it('returns `null`', () => { + expect(initNewAccessTokenApp()).toBe(null); + }); + }); + + describe('initTokensApp', () => { + it('mounts the component and provides`tokenTypes` ', () => { + const tokensData = { + [FEED_TOKEN]: FEED_TOKEN, + [INCOMING_EMAIL_TOKEN]: INCOMING_EMAIL_TOKEN, + [STATIC_OBJECT_TOKEN]: STATIC_OBJECT_TOKEN, + }; + setHTMLFixture( + `
`, + ); + + const FakeTokensApp = Vue.component('FakeComponent', { + inject: ['tokenTypes'], + props: ['tokenTypes'], + render: () => null, + }); + TokensApp.default = FakeTokensApp; + + const vueInstance = initTokensApp(); + + wrapper = createWrapper(vueInstance); + const component = wrapper.findComponent(FakeTokensApp); + + expect(component.exists()).toBe(true); + expect(component.props('tokenTypes')).toEqual(tokensData); + }); + + it('returns `null`', () => { + expect(initNewAccessTokenApp()).toBe(null); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index e636f58d868..6d176e1bf6a 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -236,4 +236,32 @@ describe('InputCopyToggleVisibility', () => { expect(wrapper.findByText(description).exists()).toBe(true); }); + + it('passes `inputClass` prop to `GlFormInputGroup`', () => { + createComponent(); + expect(findFormInputGroup().props('inputClass')).toBe('gl-font-monospace! gl-cursor-default!'); + wrapper.destroy(); + + createComponent({ + propsData: { + inputClass: 'Foo bar', + }, + }); + expect(findFormInputGroup().props('inputClass')).toBe( + 'gl-font-monospace! gl-cursor-default! Foo bar', + ); + }); + + it('passes `qaSelector` prop as an `data-qa-selector` attribute to `GlFormInputGroup`', () => { + createComponent(); + expect(findFormInputGroup().attributes('data-qa-selector')).toBeUndefined(); + wrapper.destroy(); + + createComponent({ + propsData: { + qaSelector: 'Foo bar', + }, + }); + expect(findFormInputGroup().attributes('data-qa-selector')).toBe('Foo bar'); + }); }); diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb index 8eab0222cf6..67e23c05a67 100644 --- a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb +++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb @@ -90,6 +90,8 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do end context 'and they do not match' do + subject(:result) { resolve_versions(object) } + let(:params) do { earlier_or_equal_to_sha: first_version.sha, diff --git a/spec/lib/api/entities/personal_access_token_with_details_spec.rb b/spec/lib/api/entities/personal_access_token_with_details_spec.rb new file mode 100644 index 00000000000..a53d6febba1 --- /dev/null +++ b/spec/lib/api/entities/personal_access_token_with_details_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::PersonalAccessTokenWithDetails do + describe '#as_json' do + let_it_be(:user) { create(:user) } + let_it_be(:token) { create(:personal_access_token, user: user, expires_at: nil) } + + let(:entity) { described_class.new(token) } + + it 'returns token data' do + expect(entity.as_json).to eq({ + id: token.id, + name: token.name, + revoked: false, + created_at: token.created_at, + scopes: ['api'], + user_id: user.id, + last_used_at: nil, + active: true, + expires_at: nil, + expired: false, + expires_soon: false, + revoke_path: Gitlab::Routing.url_helpers.revoke_profile_personal_access_token_path(token) + }) + end + end +end diff --git a/spec/lib/gitlab/graphql/markdown_field_spec.rb b/spec/lib/gitlab/graphql/markdown_field_spec.rb index ed3f19d8cf2..84494d3dd68 100644 --- a/spec/lib/gitlab/graphql/markdown_field_spec.rb +++ b/spec/lib/gitlab/graphql/markdown_field_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::Graphql::MarkdownField do include Gitlab::Routing + include GraphqlHelpers describe '.markdown_field' do it 'creates the field with some default attributes' do @@ -33,8 +34,7 @@ RSpec.describe Gitlab::Graphql::MarkdownField do context 'resolving markdown' do let_it_be(:note) { build(:note, note: '# Markdown!') } let_it_be(:expected_markdown) { '

Markdown!

' } - let_it_be(:query_type) { GraphQL::ObjectType.new } - let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)} + let_it_be(:schema) { empty_schema } let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) } let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: {}, object: nil) } diff --git a/spec/support/helpers/countries_controller_test_helper.rb b/spec/support/helpers/countries_controller_test_helper.rb new file mode 100644 index 00000000000..5d36a29bba7 --- /dev/null +++ b/spec/support/helpers/countries_controller_test_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module CountriesControllerTestHelper + def world_deny_list + ::World::DENYLIST + ::World::JH_MARKET + end +end + +CountriesControllerTestHelper.prepend_mod diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb index 264281ef94a..35fa69481a9 100644 --- a/spec/support/helpers/gitaly_setup.rb +++ b/spec/support/helpers/gitaly_setup.rb @@ -369,7 +369,7 @@ module GitalySetup message += "- The `gitaly` binary does not exist: #{gitaly_binary}\n" unless File.exist?(gitaly_binary) message += "- The `praefect` binary does not exist: #{praefect_binary}\n" unless File.exist?(praefect_binary) - message += "- The `git` binary does not exist: #{git_binary}\n" unless File.exist?(git_binary) + message += "- No `git` binaries exist\n" if git_binaries.empty? message += "\nCheck log/gitaly-test.log & log/praefect-test.log for errors.\n" @@ -381,8 +381,8 @@ module GitalySetup message end - def git_binary - File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git") + def git_binaries + Dir.glob(File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git-v*")) end def gitaly_binary @@ -392,8 +392,4 @@ module GitalySetup def praefect_binary File.join(tmp_tests_gitaly_dir, "_build", "bin", "praefect") end - - def git_binary_exists? - File.exist?(git_binary) - end end diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb index 215d9d3e5a8..5fa96443608 100644 --- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb +++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb @@ -51,7 +51,7 @@ RSpec.shared_examples 'resource access tokens creation disallowed' do |error_mes it 'does not show access token creation form' do visit resource_settings_access_tokens_path - expect(page).not_to have_selector('#new_resource_access_token') + expect(page).not_to have_selector('#js-new-access-token-form') end it 'shows access token creation disabled text' do -- cgit v1.2.1