diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-23 09:08:01 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-23 09:08:01 +0000 |
commit | c71c2ba4c29ed3cc483e528a32f34816c98c39f4 (patch) | |
tree | 56a8a8355631b9d58544bb74816082529ce0044d | |
parent | 5534414cd55a3e85d8f60e1a0883ed32f190df6b (diff) | |
download | gitlab-ce-c71c2ba4c29ed3cc483e528a32f34816c98c39f4.tar.gz |
Add latest changes from gitlab-org/gitlab@master
38 files changed, 1397 insertions, 169 deletions
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 @@ +<script> +import { GlButton, GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { __, s__, sprintf } from '~/locale'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import UserDate from '~/vue_shared/components/user_date.vue'; + +const FORM_SELECTOR = '#js-new-access-token-form'; +const SUCCESS_EVENT = 'ajax:success'; + +export default { + FORM_SELECTOR, + SUCCESS_EVENT, + name: 'AccessTokenTableApp', + components: { + DomElementListener, + GlButton, + GlIcon, + GlLink, + GlTable, + TimeAgoTooltip, + UserDate, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + lastUsedHelpLink: helpPagePath('/user/profile/personal_access_tokens.md', { + anchor: 'view-the-last-time-a-token-was-used', + }), + i18n: { + emptyField: __('Never'), + expired: __('Expired'), + header: __('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), + modalMessage: __( + 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', + ), + revokeButton: __('Revoke'), + tokenValidity: __('Token valid until revoked'), + }, + fields: [ + { + key: 'name', + label: __('Token name'), + sortable: true, + tdClass: `gl-text-black-normal`, + thClass: `gl-text-black-normal`, + }, + { + formatter(scopes) { + return scopes?.length ? scopes.join(', ') : __('no scopes selected'); + }, + key: 'scopes', + label: __('Scopes'), + sortable: true, + tdClass: `gl-text-black-normal`, + thClass: `gl-text-black-normal`, + }, + { + key: 'createdAt', + label: s__('AccessTokens|Created'), + sortable: true, + tdClass: `gl-text-black-normal`, + thClass: `gl-text-black-normal`, + }, + { + key: 'lastUsedAt', + label: __('Last Used'), + sortable: true, + tdClass: `gl-text-black-normal`, + thClass: `gl-text-black-normal`, + }, + { + key: 'expiresAt', + label: __('Expires'), + sortable: true, + tdClass: `gl-text-black-normal`, + thClass: `gl-text-black-normal`, + }, + { + key: 'role', + label: __('Role'), + tdClass: `gl-text-black-normal`, + thClass: `gl-text-black-normal`, + sortable: true, + }, + { + key: 'action', + label: __('Action'), + thClass: `gl-text-black-normal`, + }, + ], + inject: [ + 'accessTokenType', + 'accessTokenTypePlural', + 'initialActiveAccessTokens', + 'noActiveTokensMessage', + 'showRole', + ], + data() { + return { + activeAccessTokens: this.initialActiveAccessTokens, + }; + }, + computed: { + filteredFields() { + return this.showRole + ? this.$options.fields + : this.$options.fields.filter((field) => field.key !== 'role'); + }, + header() { + return sprintf(this.$options.i18n.header, { + accessTokenTypePlural: this.accessTokenTypePlural, + totalAccessTokens: this.activeAccessTokens.length, + }); + }, + modalMessage() { + return sprintf(this.$options.i18n.modalMessage, { + accessTokenType: this.accessTokenType, + }); + }, + }, + methods: { + onSuccess(event) { + const [{ active_access_tokens: activeAccessTokens }] = event.detail; + this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true }); + }, + sortingChanged(aRow, bRow, key) { + if (['createdAt', 'lastUsedAt', 'expiresAt'].includes(key)) { + // Transform `null` value to the latest possible date + // https://stackoverflow.com/a/11526569/18428169 + const maxEpoch = 8640000000000000; + const a = new Date(aRow[key] ?? maxEpoch).getTime(); + const b = new Date(bRow[key] ?? maxEpoch).getTime(); + return a - b; + } + + // For other columns the default sorting works OK + return false; + }, + }, +}; +</script> + +<template> + <dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.SUCCESS_EVENT]="onSuccess"> + <div> + <hr /> + <h5>{{ header }}</h5> + + <gl-table + data-testid="active-tokens" + :empty-text="noActiveTokensMessage" + :fields="filteredFields" + :items="activeAccessTokens" + :sort-compare="sortingChanged" + show-empty + > + <template #cell(createdAt)="{ item: { createdAt } }"> + <user-date :date="createdAt" /> + </template> + + <template #head(lastUsedAt)="{ label }"> + <span>{{ label }}</span> + <gl-link :href="$options.lastUsedHelpLink" + ><gl-icon name="question-o" /><span class="gl-sr-only">{{ + s__('AccessTokens|The last time a token was used') + }}</span></gl-link + > + </template> + + <template #cell(lastUsedAt)="{ item: { lastUsedAt } }"> + <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" /> + <template v-else> {{ $options.i18n.emptyField }}</template> + </template> + + <template #cell(expiresAt)="{ item: { expiresAt, expired, expiresSoon } }"> + <template v-if="expiresAt"> + <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span> + <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" /> + </template> + <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{ + $options.i18n.emptyField + }}</span> + </template> + + <template #cell(action)="{ item: { revokePath, expiresAt } }"> + <gl-button + variant="danger" + :category="expiresAt ? 'primary' : 'secondary'" + :aria-label="$options.i18n.revokeButton" + :data-confirm="modalMessage" + data-confirm-btn-variant="danger" + data-qa-selector="revoke_button" + data-method="put" + :href="revokePath" + icon="remove" + /> + </template> + </gl-table> + </div> + </dom-element-listener> +</template> 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 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { createAlert, VARIANT_INFO } from '~/flash'; +import { __, n__, 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'; + +const ERROR_EVENT = 'ajax:error'; +const FORM_SELECTOR = '#js-new-access-token-form'; +const SUCCESS_EVENT = 'ajax:success'; + +export default { + ERROR_EVENT, + FORM_SELECTOR, + SUCCESS_EVENT, + name: 'NewAccessTokenApp', + components: { DomElementListener, GlAlert, InputCopyToggleVisibility }, + i18n: { + alertInfoMessage: __('Your new %{accessTokenType} has been created.'), + copyButtonTitle: __('Copy %{accessTokenType}'), + description: __("Make sure you save it - you won't be able to access it again."), + label: __('Your new %{accessTokenType}'), + }, + inject: ['accessTokenType'], + data() { + return { errors: null, infoAlert: null, newToken: null }; + }, + computed: { + alertInfoMessage() { + return sprintf(this.$options.i18n.alertInfoMessage, { + accessTokenType: this.accessTokenType, + }); + }, + alertDangerTitle() { + return n__( + 'The form contains the following error:', + 'The form contains the following errors:', + this.errors?.length ?? 0, + ); + }, + copyButtonTitle() { + return sprintf(this.$options.i18n.copyButtonTitle, { accessTokenType: this.accessTokenType }); + }, + label() { + return sprintf(this.$options.i18n.label, { accessTokenType: this.accessTokenType }); + }, + }, + mounted() { + /** @type {HTMLFormElement} */ + this.form = document.querySelector(this.$options.FORM_SELECTOR); + + /** @type {HTMLInputElement} */ + this.submitButton = this.form.querySelector('input[type=submit]'); + }, + methods: { + beforeDisplayResults() { + this.infoAlert?.dismiss(); + this.$refs.container.scrollIntoView(false); + + this.errors = null; + this.newToken = null; + }, + onError(event) { + this.beforeDisplayResults(); + + const [{ errors }] = event.detail; + this.errors = errors; + + this.submitButton.classList.remove('disabled'); + }, + onSuccess(event) { + this.beforeDisplayResults(); + + const [{ new_token: newToken }] = event.detail; + this.newToken = newToken; + + this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO }); + + this.form.reset(); + }, + }, +}; +</script> + +<template> + <dom-element-listener + :selector="$options.FORM_SELECTOR" + @[$options.ERROR_EVENT]="onError" + @[$options.SUCCESS_EVENT]="onSuccess" + > + <div ref="container"> + <template v-if="newToken"> + <!-- + After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is + closed remove the `initial-visibility` and `input-class` props. + --> + <input-copy-toggle-visibility + data-testid="new-access-token" + :copy-button-title="copyButtonTitle" + :label="label" + :value="newToken" + initial-visibility + input-class="qa-created-access-token" + qa-selector="created_access_token_field" + > + <template #description> + {{ $options.i18n.description }} + </template> + </input-copy-toggle-visibility> + <hr /> + </template> + + <template v-if="errors"> + <gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null"> + <ul class="m-0"> + <li v-for="error in errors" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> + <hr /> + </template> + </div> + </dom-element-listener> +</template> 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 { <gl-form-group v-bind="$attrs"> <gl-form-input-group :value="displayedValue" - input-class="gl-font-monospace! gl-cursor-default!" + :input-class="classInput" + :data-qa-selector="qaSelector" select-on-click readonly v-bind="formInputGroupProps" diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index dd9581c4692..217c6a7567c 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -24,10 +24,6 @@ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper { padding-right: $gutter-collapsed-width; } - - .merge-request-tabs-holder.affix { - right: $gutter-collapsed-width; - } } } @@ -81,14 +77,6 @@ .content-wrapper { padding-right: $gutter-width; } - - &:not(.with-overlay) .merge-request-tabs-holder.affix { - right: $gutter-width; - } - - &.with-overlay .merge-request-tabs-holder.affix { - right: $gutter-collapsed-width; - } } } @@ -110,10 +98,6 @@ } } -.with-performance-bar .right-sidebar.affix { - top: calc(#{$header-height} + #{$performance-bar-height}); -} - @mixin maintain-sidebar-dimensions { display: block; width: $gutter-width; diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index d55d6b27576..9213754d419 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -44,13 +44,6 @@ } } - &.affix-top { - position: absolute; - right: 0; - left: 0; - top: 0; - } - .controllers { @include build-controllers(15px, center, false, 0, inline, 0); } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 5062a7bb8fa..451b4022d41 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -263,10 +263,6 @@ } } - &.affix-top .issuable-sidebar { - height: 100%; - } - &.right-sidebar-expanded { &:not(.right-sidebar-merge-requests) { width: $gutter-width; diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 543ae1df1af..fbfb1398889 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -247,12 +247,6 @@ $tabs-holder-z-index: 250; } } -.merge-request-tabs-holder.affix .merge-request-tabs-container, -.epic-tabs-holder.affix .epic-tabs-container { - padding-left: $gl-padding; - padding-right: $gl-padding; -} - .with-performance-bar { .merge-request-tabs-holder, .epic-tabs-holder { @@ -331,8 +325,7 @@ $tabs-holder-z-index: 250; } .limit-container-width:not(.container-limited) { - .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container, - .epic-tabs-holder:not(.affix) .epic-tabs-container { + .merge-request-tabs-holder .merge-request-tabs-container { max-width: $limited-layout-width - ($gl-padding * 2); } } diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index ad2e384077a..5c5d4008d00 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -23,12 +23,21 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController @personal_access_token = result.payload[:personal_access_token] - if result.success? - PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token) - redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.") + if Feature.enabled?(:access_token_ajax) + if result.success? + render json: { new_token: @personal_access_token.token, + active_access_tokens: active_personal_access_tokens }, status: :ok + else + render json: { errors: result.errors }, status: :unprocessable_entity + end else - set_index_vars - render :index + if result.success? + PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token) + redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.") + else + set_index_vars + render :index + end end end @@ -52,14 +61,20 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def set_index_vars @scopes = Gitlab::Auth.available_scopes_for(current_user) - - @inactive_personal_access_tokens = finder(state: 'inactive').execute @active_personal_access_tokens = active_personal_access_tokens - @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id) + if Feature.disabled?(:access_token_ajax) + @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id) + end end def active_personal_access_tokens - finder(state: 'active', sort: 'expires_at_asc').execute + tokens = finder(state: 'active', sort: 'expires_at_asc').execute + + if Feature.enabled?(:access_token_ajax) + ::API::Entities::PersonalAccessTokenWithDetails.represent(tokens) + else + tokens + end end end diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index 0d65431d870..980e9bdcdad 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -2,6 +2,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController feature_category :five_minute_production_app + urgency :low before_action :admin_project_google_cloud! before_action :google_oauth2_enabled! diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index de44dbb26d7..ea737f54a95 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -66,7 +66,6 @@ module IssueResolverArguments description: 'Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues.' argument :not, Types::Issues::NegatedIssueFilterInputType, description: 'Negated arguments.', - prepare: ->(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 + "<br />" + rebaseUpToDate + "<br />" + rebaseSemiLinear).html_safe + = form.gitlab_ui_radio_component :merge_method, + :ff, + labelFastForward, + help_text: (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + 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('<form id="js-new-access-token-form"><input type="submit"/></form>'); + + 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( + `<div id="js-access-token-table-app" + data-access-token-type="${accessTokenType}" + data-access-token-type-plural="${accessTokenTypePlural}" + data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)} + > + </div>`, + ); + + 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( + `<div id="js-access-token-table-app" + data-access-token-type="${accessTokenType}" + data-access-token-type-plural="${accessTokenTypePlural}" + data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)} + data-no-active-tokens-message="${noActiveTokensMessage}" + data-show-role + > + </div>`, + ); + + 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( + `<div class="${mountSelector}"> + <input + name="${nameAttribute}" + data-js-name="${fieldName}" + id="${idAttribute}" + placeholder="Foo bar" + value="1,2" + /> + </div>`, + ); // 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( + `<div id="js-new-access-token-app" data-access-token-type="${accessTokenType}"></div>`, + ); + + 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( + `<div id="js-tokens-app" data-tokens-data=${JSON.stringify(tokensData)}></div>`, + ); + + 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) { '<h1 data-sourcepos="1:1-1:11" dir="auto">Markdown!</h1>' } - 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 |