Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									c2acba9468
								
							
						
					
					
						commit
						f25663fd8e
					
				|  | @ -1,13 +1,24 @@ | |||
| <script> | ||||
| import { GlButton, GlIcon, GlLink, GlPagination, GlTable, GlTooltipDirective } from '@gitlab/ui'; | ||||
| import { helpPagePath } from '~/helpers/help_page_helper'; | ||||
| import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; | ||||
| import axios from '~/lib/utils/axios_utils'; | ||||
| import { | ||||
|   convertObjectPropsToCamelCase, | ||||
|   normalizeHeaders, | ||||
|   parseIntPagination, | ||||
| } from '~/lib/utils/common_utils'; | ||||
| import { __, 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'; | ||||
| import { EVENT_SUCCESS, FIELDS, FORM_SELECTOR, INITIAL_PAGE, PAGE_SIZE } from './constants'; | ||||
| 
 | ||||
| /** | ||||
|  * This component supports two different types of pagination: | ||||
|  * 1. Frontend only pagination: all the data is passed to the frontend. The UI slices and displays the tokens. | ||||
|  * 2. Backend pagination: backend sends only the data corresponding to the `page` parameter. | ||||
|  */ | ||||
| 
 | ||||
| export default { | ||||
|   EVENT_SUCCESS, | ||||
|   FORM_SELECTOR, | ||||
|  | @ -41,16 +52,21 @@ export default { | |||
|   inject: [ | ||||
|     'accessTokenType', | ||||
|     'accessTokenTypePlural', | ||||
|     'backendPagination', | ||||
|     'initialActiveAccessTokens', | ||||
|     'noActiveTokensMessage', | ||||
|     'showRole', | ||||
|   ], | ||||
|   data() { | ||||
|     const activeAccessTokens = this.convert(this.initialActiveAccessTokens); | ||||
| 
 | ||||
|     return { | ||||
|       activeAccessTokens: convertObjectPropsToCamelCase(this.initialActiveAccessTokens, { | ||||
|         deep: true, | ||||
|       }), | ||||
|       currentPage: INITIAL_PAGE, | ||||
|       activeAccessTokens, | ||||
|       busy: false, | ||||
|       currentPage: INITIAL_PAGE, // This is the page use in the GlTable. It stays 1 if the backend pagination is on. | ||||
|       page: INITIAL_PAGE, // This is the page use in the GlPagination component | ||||
|       perPage: PAGE_SIZE, | ||||
|       totalItems: activeAccessTokens.length, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|  | @ -70,17 +86,67 @@ export default { | |||
|         ignoredFields.push('role'); | ||||
|       } | ||||
| 
 | ||||
|       return FIELDS.filter(({ key }) => !ignoredFields.includes(key)); | ||||
|       const fields = FIELDS.filter(({ key }) => !ignoredFields.includes(key)); | ||||
| 
 | ||||
|       // Remove the sortability of the columns if backend pagination is on. | ||||
|       if (this.backendPagination) { | ||||
|         return fields.map((field) => ({ | ||||
|           ...field, | ||||
|           sortable: false, | ||||
|         })); | ||||
|       } | ||||
| 
 | ||||
|       return fields; | ||||
|     }, | ||||
|     showPagination() { | ||||
|       return this.activeAccessTokens.length > PAGE_SIZE; | ||||
|       return this.totalItems > this.perPage; | ||||
|     }, | ||||
|   }, | ||||
|   created() { | ||||
|     if (this.backendPagination) { | ||||
|       this.fetchData(); | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     convert(accessTokens) { | ||||
|       return convertObjectPropsToCamelCase(accessTokens, { deep: true }); | ||||
|     }, | ||||
|     async fetchData(newPage) { | ||||
|       const url = new URL(document.location.href); | ||||
|       url.pathname = `${url.pathname}.json`; | ||||
| 
 | ||||
|       if (newPage) { | ||||
|         url.searchParams.delete('page'); | ||||
|         url.searchParams.append('page', newPage); | ||||
|       } | ||||
| 
 | ||||
|       this.busy = true; | ||||
|       const { data, headers } = await axios.get(url.toString()); | ||||
| 
 | ||||
|       const { page, perPage, total } = parseIntPagination(normalizeHeaders(headers)); | ||||
|       this.page = page; | ||||
|       this.perPage = perPage; | ||||
|       this.totalItems = total; | ||||
|       this.busy = false; | ||||
| 
 | ||||
|       if (newPage) { | ||||
|         this.activeAccessTokens = this.convert(data); | ||||
|         this.replaceHistory(newPage); | ||||
|       } | ||||
|     }, | ||||
|     replaceHistory(page) { | ||||
|       window.history.replaceState(null, '', `?page=${page}`); | ||||
|     }, | ||||
|     onSuccess(event) { | ||||
|       const [{ active_access_tokens: activeAccessTokens }] = event.detail; | ||||
|       this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true }); | ||||
|       const [{ active_access_tokens: activeAccessTokens, total: totalItems }] = event.detail; | ||||
|       this.activeAccessTokens = this.convert(activeAccessTokens); | ||||
|       this.totalItems = totalItems; | ||||
|       this.currentPage = INITIAL_PAGE; | ||||
|       this.page = INITIAL_PAGE; | ||||
| 
 | ||||
|       if (this.backendPagination) { | ||||
|         this.replaceHistory(INITIAL_PAGE); | ||||
|       } | ||||
|     }, | ||||
|     modalMessage(tokenName) { | ||||
|       return sprintf(this.$options.i18n.modalMessage, { | ||||
|  | @ -101,6 +167,15 @@ export default { | |||
|       // For other columns the default sorting works OK | ||||
|       return false; | ||||
|     }, | ||||
|     async pageChanged(newPage) { | ||||
|       if (this.backendPagination) { | ||||
|         await this.fetchData(newPage); | ||||
|       } else { | ||||
|         this.currentPage = newPage; | ||||
|         this.page = newPage; | ||||
|       } | ||||
|       window.scrollTo({ top: 0 }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | @ -114,11 +189,12 @@ export default { | |||
|           :empty-text="noActiveTokensMessage" | ||||
|           :fields="filteredFields" | ||||
|           :items="activeAccessTokens" | ||||
|           :per-page="$options.PAGE_SIZE" | ||||
|           :per-page="perPage" | ||||
|           :current-page="currentPage" | ||||
|           :sort-compare="sortingChanged" | ||||
|           show-empty | ||||
|           stacked="sm" | ||||
|           :busy="busy" | ||||
|         > | ||||
|           <template #cell(createdAt)="{ item: { createdAt } }"> | ||||
|             <user-date :date="createdAt" /> | ||||
|  | @ -167,11 +243,13 @@ export default { | |||
|       </div> | ||||
|       <gl-pagination | ||||
|         v-if="showPagination" | ||||
|         v-model="currentPage" | ||||
|         :per-page="$options.PAGE_SIZE" | ||||
|         :total-items="activeAccessTokens.length" | ||||
|         :value="page" | ||||
|         :per-page="perPage" | ||||
|         :total-items="totalItems" | ||||
|         :disabled="busy" | ||||
|         align="center" | ||||
|         class="gl-mt-5" | ||||
|         @input="pageChanged" | ||||
|       /> | ||||
|     </div> | ||||
|   </dom-element-listener> | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| import Vue from 'vue'; | ||||
| 
 | ||||
| import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; | ||||
| import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; | ||||
| import { parseRailsFormFields } from '~/lib/utils/forms'; | ||||
| import { __, sprintf } from '~/locale'; | ||||
| import Translate from '~/vue_shared/translate'; | ||||
|  | @ -23,6 +23,7 @@ export const initAccessTokenTableApp = () => { | |||
|   const { | ||||
|     accessTokenType, | ||||
|     accessTokenTypePlural, | ||||
|     backendPagination, | ||||
|     initialActiveAccessTokens: initialActiveAccessTokensJson, | ||||
|     noActiveTokensMessage: noTokensMessage, | ||||
|   } = el.dataset; | ||||
|  | @ -41,6 +42,7 @@ export const initAccessTokenTableApp = () => { | |||
|     provide: { | ||||
|       accessTokenType, | ||||
|       accessTokenTypePlural, | ||||
|       backendPagination: parseBoolean(backendPagination), | ||||
|       initialActiveAccessTokens, | ||||
|       noActiveTokensMessage, | ||||
|       showRole, | ||||
|  |  | |||
|  | @ -75,7 +75,6 @@ const Api = { | |||
|   releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id', | ||||
|   mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', | ||||
|   adminStatisticsPath: '/api/:version/application/statistics', | ||||
|   pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs', | ||||
|   pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', | ||||
|   pipelinesPath: '/api/:version/projects/:id/pipelines/', | ||||
|   createPipelinePath: '/api/:version/projects/:id/pipeline', | ||||
|  | @ -797,14 +796,6 @@ const Api = { | |||
|     return axios.get(url); | ||||
|   }, | ||||
| 
 | ||||
|   pipelineJobs(projectId, pipelineId, params) { | ||||
|     const url = Api.buildUrl(this.pipelineJobsPath) | ||||
|       .replace(':id', encodeURIComponent(projectId)) | ||||
|       .replace(':pipeline_id', encodeURIComponent(pipelineId)); | ||||
| 
 | ||||
|     return axios.get(url, { params }); | ||||
|   }, | ||||
| 
 | ||||
|   // Return all pipelines for a project or filter by query params
 | ||||
|   pipelines(id, options = {}) { | ||||
|     const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id)); | ||||
|  |  | |||
|  | @ -17,8 +17,9 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController | |||
|     @impersonation_token.organization = Current.organization | ||||
| 
 | ||||
|     if @impersonation_token.save | ||||
|       active_access_tokens = active_impersonation_tokens | ||||
|       render json: { new_token: @impersonation_token.token, | ||||
|                      active_access_tokens: active_impersonation_tokens }, status: :ok | ||||
|                      active_access_tokens: active_access_tokens, total: active_access_tokens.length }, status: :ok | ||||
|     else | ||||
|       render json: { errors: @impersonation_token.errors.full_messages }, status: :unprocessable_entity | ||||
|     end | ||||
|  |  | |||
|  | @ -32,8 +32,9 @@ module AccessTokensActions | |||
| 
 | ||||
|     if token_response.success? | ||||
|       @resource_access_token = token_response.payload[:access_token] | ||||
|       tokens, size = active_access_tokens(resource.root_ancestor) | ||||
|       render json: { new_token: @resource_access_token.token, | ||||
|                      active_access_tokens: active_access_tokens }, status: :ok | ||||
|                      active_access_tokens: tokens, total: size }, status: :ok | ||||
|     else | ||||
|       render json: { errors: token_response.errors }, status: :unprocessable_entity | ||||
|     end | ||||
|  | @ -72,7 +73,7 @@ module AccessTokensActions | |||
|     resource.members.load | ||||
| 
 | ||||
|     @scopes = Gitlab::Auth.available_scopes_for(resource) | ||||
|     @active_access_tokens = active_access_tokens | ||||
|     @active_access_tokens, @active_access_tokens_size = active_access_tokens(resource.root_ancestor) | ||||
|     if Feature.enabled?(:retain_resource_access_token_user_after_revoke, resource.root_ancestor) # rubocop:disable Style/GuardClause | ||||
|       @inactive_access_tokens = inactive_access_tokens | ||||
|     end | ||||
|  |  | |||
|  | @ -3,15 +3,16 @@ | |||
| module RenderAccessTokens | ||||
|   extend ActiveSupport::Concern | ||||
| 
 | ||||
|   def active_access_tokens | ||||
|   def active_access_tokens(user_or_group) | ||||
|     tokens = finder(state: 'active', sort: 'expires_at_asc_id_desc').execute.preload_users | ||||
|     size = tokens.size | ||||
| 
 | ||||
|     if Feature.enabled?('access_token_pagination') | ||||
|     if Feature.enabled?(:access_token_pagination, user_or_group) | ||||
|       tokens = tokens.page(page) | ||||
|       add_pagination_headers(tokens) | ||||
|     end | ||||
| 
 | ||||
|     represent(tokens) | ||||
|     [represent(tokens), size] | ||||
|   end | ||||
| 
 | ||||
|   def inactive_access_tokens | ||||
|  |  | |||
|  | @ -45,9 +45,10 @@ module UserSettings | |||
| 
 | ||||
|       @personal_access_token = result.payload[:personal_access_token] | ||||
| 
 | ||||
|       tokens, size = active_access_tokens(current_user) | ||||
|       if result.success? | ||||
|         render json: { new_token: @personal_access_token.token, | ||||
|                        active_access_tokens: active_access_tokens }, status: :ok | ||||
|                        active_access_tokens: tokens, total: size }, status: :ok | ||||
|       else | ||||
|         render json: { errors: result.errors }, status: :unprocessable_entity | ||||
|       end | ||||
|  | @ -73,7 +74,7 @@ module UserSettings | |||
| 
 | ||||
|     def set_index_vars | ||||
|       @scopes = Gitlab::Auth.available_scopes_for(current_user) | ||||
|       @active_access_tokens = active_access_tokens | ||||
|       @active_access_tokens, @active_access_tokens_size = active_access_tokens(current_user) | ||||
|     end | ||||
| 
 | ||||
|     def represent(tokens) | ||||
|  |  | |||
|  | @ -22,6 +22,9 @@ module Types | |||
|     field :expires_at, Types::TimeType, null: true, | ||||
|       description: 'Date and time the membership expires.' | ||||
| 
 | ||||
|     field :last_activity_on, Types::TimeType, | ||||
|       description: 'Date of last activity in the namespace (group or project).' | ||||
| 
 | ||||
|     field :user, Types::UserType, null: true, | ||||
|       description: 'User that is associated with the member object.' | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,7 +24,7 @@ | |||
| 
 | ||||
|     = render ::Layouts::CrudComponent.new(_('Active group access tokens'), | ||||
|       icon: 'token', | ||||
|       count: @active_access_tokens.size, | ||||
|       count: @active_access_tokens_size, | ||||
|       count_options: { class: 'js-token-count' }, | ||||
|       form_options: { class: 'gl-hidden js-toggle-content js-add-new-token-form' }, | ||||
|       options: { class: 'js-toggle-container js-token-card' }) do |c| | ||||
|  | @ -49,12 +49,13 @@ | |||
|             help_path: help_page_path('user/group/settings/group_access_tokens.md', anchor: 'scopes-for-a-group-access-token') | ||||
| 
 | ||||
|       - c.with_body do | ||||
|         #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } } | ||||
|         #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: Feature.enabled?(:access_token_pagination, @group.root_ancestor).to_s, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } } | ||||
| 
 | ||||
|     - if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @group.root_ancestor) | ||||
|       = render ::Layouts::CrudComponent.new(_('Inactive group access tokens'), | ||||
|         icon: 'token', | ||||
|         count: @inactive_access_tokens.size, | ||||
|         count_options: { class: 'js-token-count' }) do |c| | ||||
|         - c.with_body do | ||||
|           #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This group has no inactive access tokens.')} } | ||||
|       .gl-mt-5 | ||||
|         = render ::Layouts::CrudComponent.new(_('Inactive group access tokens'), | ||||
|           icon: 'token', | ||||
|           count: @inactive_access_tokens.size, | ||||
|           count_options: { class: 'js-token-count' }) do |c| | ||||
|           - c.with_body do | ||||
|             #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This group has no inactive access tokens.')} } | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ | |||
| 
 | ||||
|   = render ::Layouts::CrudComponent.new(_('Active project access tokens'), | ||||
|     icon: 'token', | ||||
|     count: @active_access_tokens.size, | ||||
|     count: @active_access_tokens_size, | ||||
|     count_options: { class: 'js-token-count' }, | ||||
|     form_options: { class: 'gl-hidden js-toggle-content js-add-new-token-form' }, | ||||
|     options: { class: 'gl-mt-5 js-toggle-container js-token-card' }) do |c| | ||||
|  | @ -40,12 +40,13 @@ | |||
|         = render_if_exists 'projects/settings/access_tokens/form', type: type | ||||
| 
 | ||||
|     - c.with_body do | ||||
|       #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } } | ||||
|       #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: Feature.enabled?(:access_token_pagination, @project.root_ancestor).to_s, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } } | ||||
| 
 | ||||
|   - if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @project.root_ancestor) | ||||
|     = render ::Layouts::CrudComponent.new(_('Inactive project access tokens'), | ||||
|       icon: 'token', | ||||
|       count: @inactive_access_tokens.size, | ||||
|       count_options: { class: 'js-token-count' }) do |c| | ||||
|       - c.with_body do | ||||
|         #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This project has no inactive access tokens.')} } | ||||
|     .gl-mt-5 | ||||
|       = render ::Layouts::CrudComponent.new(_('Inactive project access tokens'), | ||||
|         icon: 'token', | ||||
|         count: @inactive_access_tokens.size, | ||||
|         count_options: { class: 'js-token-count' }) do |c| | ||||
|         - c.with_body do | ||||
|           #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This project has no inactive access tokens.')} } | ||||
|  |  | |||
|  | @ -18,7 +18,7 @@ | |||
| 
 | ||||
|   = render ::Layouts::CrudComponent.new(_('Active personal access tokens'), | ||||
|     icon: 'token', | ||||
|     count: @active_access_tokens.size, | ||||
|     count: @active_access_tokens_size, | ||||
|     count_options: { class: 'js-token-count' }, | ||||
|     toggle_text: _('Add new token'), | ||||
|     toggle_options: { data: { testid: 'add-new-token-button' } }, | ||||
|  | @ -34,6 +34,6 @@ | |||
|         help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') | ||||
| 
 | ||||
|     - c.with_body do | ||||
|       #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } } | ||||
|       #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: Feature.enabled?(:access_token_pagination, current_user).to_s, initial_active_access_tokens: @active_access_tokens.to_json } } | ||||
| 
 | ||||
| #js-tokens-app{ data: { tokens_data: tokens_app_data } } | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| --- | ||||
| migration_job_name: BackfillMembersRequestAcceptedAt | ||||
| description: Backfills `request_accepted_at` column in the `members` table to `created_at` column value for all existing records. | ||||
| feature_category: groups_and_projects | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/166812 | ||||
| milestone: '17.5' | ||||
| queued_migration_version: 20240920083708 | ||||
| finalized_by: | ||||
|  | @ -0,0 +1,19 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class DropIndexForOwaspTop10GroupLevelReports < Gitlab::Database::Migration[2.2] | ||||
|   disable_ddl_transaction! | ||||
|   milestone '17.6' | ||||
| 
 | ||||
|   INDEX_NAME = 'index_for_owasp_top_10_group_level_reports' | ||||
| 
 | ||||
|   def up | ||||
|     remove_concurrent_index_by_name :vulnerability_reads, INDEX_NAME | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     add_concurrent_index :vulnerability_reads, [:owasp_top_10, :state, :report_type, | ||||
|       :severity, :traversal_ids, :vulnerability_id, :resolved_on_default_branch], | ||||
|       where: 'archived = false', | ||||
|       name: INDEX_NAME | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,26 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class QueueBackfillMembersRequestAcceptedAt < Gitlab::Database::Migration[2.2] | ||||
|   milestone '17.5' | ||||
|   restrict_gitlab_migration gitlab_schema: :gitlab_main | ||||
| 
 | ||||
|   MIGRATION = "BackfillMembersRequestAcceptedAt" | ||||
|   DELAY_INTERVAL = 2.minutes | ||||
|   BATCH_SIZE = 1000 | ||||
|   SUB_BATCH_SIZE = 100 | ||||
| 
 | ||||
|   def up | ||||
|     queue_batched_background_migration( | ||||
|       MIGRATION, | ||||
|       :members, | ||||
|       :id, | ||||
|       job_interval: DELAY_INTERVAL, | ||||
|       batch_size: BATCH_SIZE, | ||||
|       sub_batch_size: SUB_BATCH_SIZE | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     delete_batched_background_migration(MIGRATION, :members, :id, []) | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1 @@ | |||
| 1c3c404213d4eaaa59cb40e025121787d790daf0a6e42bfd04ad7ccb657fae7d | ||||
|  | @ -0,0 +1 @@ | |||
| 487b9bcc614eef44330b4c5024b0b9383a6271981f436fda86a0d95b93689e70 | ||||
|  | @ -29162,8 +29162,6 @@ CREATE UNIQUE INDEX index_feature_gates_on_feature_key_and_key_and_value ON feat | |||
| 
 | ||||
| CREATE UNIQUE INDEX index_features_on_key ON features USING btree (key); | ||||
| 
 | ||||
| CREATE INDEX index_for_owasp_top_10_group_level_reports ON vulnerability_reads USING btree (owasp_top_10, state, report_type, severity, traversal_ids, vulnerability_id, resolved_on_default_branch) WHERE (archived = false); | ||||
| 
 | ||||
| CREATE INDEX index_for_protected_environment_group_id_of_protected_environme ON protected_environment_deploy_access_levels USING btree (protected_environment_group_id); | ||||
| 
 | ||||
| CREATE INDEX index_for_protected_environment_project_id_of_protected_environ ON protected_environment_deploy_access_levels USING btree (protected_environment_project_id); | ||||
|  |  | |||
|  | @ -25155,6 +25155,7 @@ Represents a Group Membership. | |||
| | <a id="groupmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. | | ||||
| | <a id="groupmembergroup"></a>`group` | [`Group`](#group) | Group that a user is a member of. | | ||||
| | <a id="groupmemberid"></a>`id` | [`ID!`](#id) | ID of the member. | | ||||
| | <a id="groupmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). | | ||||
| | <a id="groupmembernotificationemail"></a>`notificationEmail` | [`String`](#string) | Group notification email for user. Only available for admins. | | ||||
| | <a id="groupmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. | | ||||
| | <a id="groupmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. | | ||||
|  | @ -29459,6 +29460,7 @@ Represents a Pending Group Membership. | |||
| | <a id="pendinggroupmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. | | ||||
| | <a id="pendinggroupmemberid"></a>`id` | [`ID!`](#id) | ID of the member. | | ||||
| | <a id="pendinggroupmemberinvited"></a>`invited` | [`Boolean`](#boolean) | Whether the pending member has been invited. | | ||||
| | <a id="pendinggroupmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). | | ||||
| | <a id="pendinggroupmembername"></a>`name` | [`String`](#string) | Name of the pending member. | | ||||
| | <a id="pendinggroupmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. | | ||||
| | <a id="pendinggroupmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. | | ||||
|  | @ -29496,6 +29498,7 @@ Represents a Pending Project Membership. | |||
| | <a id="pendingprojectmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. | | ||||
| | <a id="pendingprojectmemberid"></a>`id` | [`ID!`](#id) | ID of the member. | | ||||
| | <a id="pendingprojectmemberinvited"></a>`invited` | [`Boolean`](#boolean) | Whether the pending member has been invited. | | ||||
| | <a id="pendingprojectmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). | | ||||
| | <a id="pendingprojectmembername"></a>`name` | [`String`](#string) | Name of the pending member. | | ||||
| | <a id="pendingprojectmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. | | ||||
| | <a id="pendingprojectmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. | | ||||
|  | @ -32226,6 +32229,7 @@ Represents a Project Membership. | |||
| | <a id="projectmembercreatedby"></a>`createdBy` | [`UserCore`](#usercore) | User that authorized membership. | | ||||
| | <a id="projectmemberexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. | | ||||
| | <a id="projectmemberid"></a>`id` | [`ID!`](#id) | ID of the member. | | ||||
| | <a id="projectmemberlastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). | | ||||
| | <a id="projectmemberproject"></a>`project` | [`Project`](#project) | Project that User is a member of. | | ||||
| | <a id="projectmemberupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. | | ||||
| | <a id="projectmemberuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. | | ||||
|  | @ -41449,6 +41453,7 @@ Implementations: | |||
| | <a id="memberinterfacecreatedby"></a>`createdBy` | [`UserCore`](#usercore) | User that authorized membership. | | ||||
| | <a id="memberinterfaceexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. | | ||||
| | <a id="memberinterfaceid"></a>`id` | [`ID!`](#id) | ID of the member. | | ||||
| | <a id="memberinterfacelastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). | | ||||
| | <a id="memberinterfaceupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. | | ||||
| | <a id="memberinterfaceuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. | | ||||
| 
 | ||||
|  | @ -41563,6 +41568,7 @@ Implementations: | |||
| | <a id="pendingmemberinterfaceexpiresat"></a>`expiresAt` | [`Time`](#time) | Date and time the membership expires. | | ||||
| | <a id="pendingmemberinterfaceid"></a>`id` | [`ID!`](#id) | ID of the member. | | ||||
| | <a id="pendingmemberinterfaceinvited"></a>`invited` | [`Boolean`](#boolean) | Whether the pending member has been invited. | | ||||
| | <a id="pendingmemberinterfacelastactivityon"></a>`lastActivityOn` | [`Time`](#time) | Date of last activity in the namespace (group or project). | | ||||
| | <a id="pendingmemberinterfacename"></a>`name` | [`String`](#string) | Name of the pending member. | | ||||
| | <a id="pendingmemberinterfaceupdatedat"></a>`updatedAt` | [`Time`](#time) | Date and time the membership was last updated. | | ||||
| | <a id="pendingmemberinterfaceuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member object. | | ||||
|  |  | |||
|  | @ -77,4 +77,6 @@ Updated files: | |||
| 
 | ||||
| After running the script, you must commit all the modified files to Git and create a merge request. | ||||
| 
 | ||||
| The script is part of GDK and a frontend or backend developer can run the script and prepare the merge request. | ||||
| 
 | ||||
| If a group is split into multiple groups, you need to manually update the product_group. | ||||
|  |  | |||
|  | @ -0,0 +1,21 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Gitlab | ||||
|   module BackgroundMigration | ||||
|     class BackfillMembersRequestAcceptedAt < BatchedMigrationJob | ||||
|       operation_name :backfill_members_request_accepted_at | ||||
|       feature_category :groups_and_projects | ||||
| 
 | ||||
|       def perform | ||||
|         each_sub_batch do |sub_batch| | ||||
|           sub_batch | ||||
|             .where(requested_at: nil) | ||||
|             .where(invite_token: nil) | ||||
|             .where(invite_accepted_at: nil) | ||||
|             .where(request_accepted_at: nil) | ||||
|             .update_all("request_accepted_at = created_at") | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -43,6 +43,11 @@ RSpec.describe QA::Runtime::Namespace do | |||
|   end | ||||
| 
 | ||||
|   describe '.path' do | ||||
|     before do | ||||
|       allow(QA::Runtime::Scenario).to receive(:gitlab_address).and_return("http://gitlab.test") | ||||
|       described_class.instance_variable_set(:@sandbox_name, nil) | ||||
|     end | ||||
| 
 | ||||
|     it 'is always cached' do | ||||
|       path = described_class.path | ||||
|       expect(described_class.path).to eq(path) | ||||
|  |  | |||
|  | @ -1,17 +1,20 @@ | |||
| import { GlButton, GlPagination, GlTable } from '@gitlab/ui'; | ||||
| import MockAdapter from 'axios-mock-adapter'; | ||||
| import { nextTick } from 'vue'; | ||||
| import { mountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; | ||||
| import { EVENT_SUCCESS, PAGE_SIZE } from '~/access_tokens/components/constants'; | ||||
| import axios from '~/lib/utils/axios_utils'; | ||||
| import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; | ||||
| import { sprintf } from '~/locale'; | ||||
| import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; | ||||
| 
 | ||||
| describe('~/access_tokens/components/access_token_table_app', () => { | ||||
|   let wrapper; | ||||
|   let mockAxios; | ||||
| 
 | ||||
|   const accessTokenType = 'personal access token'; | ||||
|   const accessTokenTypePlural = 'personal access tokens'; | ||||
|   const information = undefined; | ||||
|   const noActiveTokensMessage = 'This user has no active personal access tokens.'; | ||||
|   const showRole = false; | ||||
| 
 | ||||
|  | @ -47,7 +50,6 @@ describe('~/access_tokens/components/access_token_table_app', () => { | |||
|       provide: { | ||||
|         accessTokenType, | ||||
|         accessTokenTypePlural, | ||||
|         information, | ||||
|         initialActiveAccessTokens: defaultActiveAccessTokens, | ||||
|         noActiveTokensMessage, | ||||
|         showRole, | ||||
|  | @ -57,9 +59,9 @@ describe('~/access_tokens/components/access_token_table_app', () => { | |||
|   }; | ||||
| 
 | ||||
|   const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => { | ||||
|     wrapper | ||||
|       .findComponent(DomElementListener) | ||||
|       .vm.$emit(EVENT_SUCCESS, { detail: [{ active_access_tokens: activeAccessTokens }] }); | ||||
|     wrapper.findComponent(DomElementListener).vm.$emit(EVENT_SUCCESS, { | ||||
|       detail: [{ active_access_tokens: activeAccessTokens, total: activeAccessTokens.length }], | ||||
|     }); | ||||
|     await nextTick(); | ||||
|   }; | ||||
| 
 | ||||
|  | @ -68,199 +70,357 @@ describe('~/access_tokens/components/access_token_table_app', () => { | |||
|   const findCells = () => findTable().findAll('td'); | ||||
|   const findPagination = () => wrapper.findComponent(GlPagination); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     const headers = { | ||||
|       'X-Page': 1, | ||||
|       'X-Per-Page': 20, | ||||
|       'X-Total': defaultActiveAccessTokens.length, | ||||
|     }; | ||||
|     mockAxios = new MockAdapter(axios); | ||||
|     mockAxios.onGet().reply( | ||||
|       HTTP_STATUS_OK, | ||||
|       [ | ||||
|         { | ||||
|           active_access_tokens: defaultActiveAccessTokens, | ||||
|           total: defaultActiveAccessTokens.length, | ||||
|         }, | ||||
|       ], | ||||
|       headers, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper?.destroy(); | ||||
|     mockAxios.restore(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render an empty table with a default message', () => { | ||||
|     createComponent({ initialActiveAccessTokens: [] }); | ||||
|   describe.each` | ||||
|     backendPagination | ||||
|     ${true} | ||||
|     ${false} | ||||
|   `('when backendPagination is $backendPagination', ({ backendPagination }) => {
 | ||||
|     it('should render an empty table with a default message', () => { | ||||
|       createComponent({ initialActiveAccessTokens: [], backendPagination }); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
|     expect(cells).toHaveLength(1); | ||||
|     expect(cells.at(0).text()).toBe( | ||||
|       sprintf('This user has no active %{accessTokenTypePlural}.', { accessTokenTypePlural }), | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render an empty table with a custom message', () => { | ||||
|     const noTokensMessage = 'This group has no active access tokens.'; | ||||
|     createComponent({ initialActiveAccessTokens: [], noActiveTokensMessage: noTokensMessage }); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
|     expect(cells).toHaveLength(1); | ||||
|     expect(cells.at(0).text()).toBe(noTokensMessage); | ||||
|   }); | ||||
| 
 | ||||
|   describe('table headers', () => { | ||||
|     it('should include `Action` column', () => { | ||||
|       createComponent(); | ||||
| 
 | ||||
|       const headers = findHeaders(); | ||||
|       expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ | ||||
|         'Token name', | ||||
|         'Scopes', | ||||
|         'Created', | ||||
|         'Last Used', | ||||
|         'Expires', | ||||
|         'Action', | ||||
|       ]); | ||||
|       const cells = findCells(); | ||||
|       expect(cells).toHaveLength(1); | ||||
|       expect(cells.at(0).text()).toBe( | ||||
|         sprintf('This user has no active %{accessTokenTypePlural}.', { accessTokenTypePlural }), | ||||
|       ); | ||||
|     }); | ||||
| 
 | ||||
|     it('should include `Role` column', () => { | ||||
|       createComponent({ showRole: true }); | ||||
|     it('should render an empty table with a custom message', () => { | ||||
|       const noTokensMessage = 'This group has no active access tokens.'; | ||||
|       createComponent({ | ||||
|         initialActiveAccessTokens: [], | ||||
|         noActiveTokensMessage: noTokensMessage, | ||||
|         backendPagination, | ||||
|       }); | ||||
| 
 | ||||
|       const headers = findHeaders(); | ||||
|       expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ | ||||
|         'Token name', | ||||
|         'Scopes', | ||||
|         'Created', | ||||
|         'Last Used', | ||||
|         'Expires', | ||||
|         'Role', | ||||
|         'Action', | ||||
|       ]); | ||||
|       const cells = findCells(); | ||||
|       expect(cells).toHaveLength(1); | ||||
|       expect(cells.at(0).text()).toBe(noTokensMessage); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   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('The last time a token was used'); | ||||
|   }); | ||||
| 
 | ||||
|   it('updates the table after new tokens are created', async () => { | ||||
|     createComponent({ initialActiveAccessTokens: [], 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 button = cells.at(6).findComponent(GlButton); | ||||
|     expect(button.attributes()).toMatchObject({ | ||||
|       'aria-label': 'Revoke', | ||||
|       'data-testid': 'revoke-button', | ||||
|       href: '/-/user_settings/personal_access_tokens/1/revoke', | ||||
|       'data-confirm': sprintf( | ||||
|         'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.', | ||||
| 
 | ||||
|         { accessTokenType, tokenName: 'a' }, | ||||
|       ), | ||||
|     }); | ||||
|     expect(button.props('category')).toBe('tertiary'); | ||||
| 
 | ||||
|     // 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'); | ||||
|     button = cells.at(13).findComponent(GlButton); | ||||
|     expect(button.attributes('href')).toBe('/-/user_settings/personal_access_tokens/2/revoke'); | ||||
|     expect(button.props('category')).toBe('tertiary'); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when revoke_path is', () => { | ||||
|     describe('absent in all tokens', () => { | ||||
|       it('should not include `Action` column', () => { | ||||
|         createComponent({ | ||||
|           initialActiveAccessTokens: defaultActiveAccessTokens.map( | ||||
|             ({ revoke_path, ...rest }) => rest, | ||||
|           ), | ||||
|           showRole: true, | ||||
|         }); | ||||
|     describe('table headers', () => { | ||||
|       it('should include `Action` column', () => { | ||||
|         createComponent({ backendPagination }); | ||||
| 
 | ||||
|         const headers = findHeaders(); | ||||
|         expect(headers).toHaveLength(6); | ||||
|         ['Token name', 'Scopes', 'Created', 'Last Used', 'Expires', 'Role'].forEach( | ||||
|           (text, index) => { | ||||
|             expect(headers.at(index).text()).toBe(text); | ||||
|           }, | ||||
|         ); | ||||
|         expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ | ||||
|           'Token name', | ||||
|           'Scopes', | ||||
|           'Created', | ||||
|           'Last Used', | ||||
|           'Expires', | ||||
|           'Action', | ||||
|         ]); | ||||
|       }); | ||||
| 
 | ||||
|       it('should include `Role` column', () => { | ||||
|         createComponent({ showRole: true, backendPagination }); | ||||
| 
 | ||||
|         const headers = findHeaders(); | ||||
|         expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ | ||||
|           'Token name', | ||||
|           'Scopes', | ||||
|           'Created', | ||||
|           'Last Used', | ||||
|           'Expires', | ||||
|           'Role', | ||||
|           'Action', | ||||
|         ]); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it.each([{ revoke_path: null }, { revoke_path: undefined }])( | ||||
|       '%p in some tokens, does not show revoke button', | ||||
|       (input) => { | ||||
|     it('`Last Used` header should contain a link and an assistive message', () => { | ||||
|       createComponent({ backendPagination }); | ||||
| 
 | ||||
|       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('The last time a token was used'); | ||||
|     }); | ||||
| 
 | ||||
|     it('updates the table after new tokens are created', async () => { | ||||
|       createComponent({ initialActiveAccessTokens: [], showRole: true, backendPagination }); | ||||
|       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 button = cells.at(6).findComponent(GlButton); | ||||
|       expect(button.attributes()).toMatchObject({ | ||||
|         'aria-label': 'Revoke', | ||||
|         'data-testid': 'revoke-button', | ||||
|         href: '/-/user_settings/personal_access_tokens/1/revoke', | ||||
|         'data-confirm': sprintf( | ||||
|           'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.', | ||||
| 
 | ||||
|           { accessTokenType, tokenName: 'a' }, | ||||
|         ), | ||||
|       }); | ||||
|       expect(button.props('category')).toBe('tertiary'); | ||||
| 
 | ||||
|       // 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'); | ||||
|       button = cells.at(13).findComponent(GlButton); | ||||
|       expect(button.attributes('href')).toBe('/-/user_settings/personal_access_tokens/2/revoke'); | ||||
|       expect(button.props('category')).toBe('tertiary'); | ||||
|     }); | ||||
| 
 | ||||
|     describe('when revoke_path is', () => { | ||||
|       describe('absent in all tokens', () => { | ||||
|         it('should not include `Action` column', () => { | ||||
|           createComponent({ | ||||
|             initialActiveAccessTokens: defaultActiveAccessTokens.map( | ||||
|               ({ revoke_path, ...rest }) => rest, | ||||
|             ), | ||||
|             showRole: true, | ||||
|             backendPagination, | ||||
|           }); | ||||
| 
 | ||||
|           const headers = findHeaders(); | ||||
|           expect(headers).toHaveLength(6); | ||||
|           ['Token name', 'Scopes', 'Created', 'Last Used', 'Expires', 'Role'].forEach( | ||||
|             (text, index) => { | ||||
|               expect(headers.at(index).text()).toBe(text); | ||||
|             }, | ||||
|           ); | ||||
|         }); | ||||
|       }); | ||||
| 
 | ||||
|       it.each([{ revoke_path: null }, { revoke_path: undefined }])( | ||||
|         '%p in some tokens, does not show revoke button', | ||||
|         (input) => { | ||||
|           createComponent({ | ||||
|             initialActiveAccessTokens: [ | ||||
|               defaultActiveAccessTokens.map((data) => ({ ...data, ...input }))[0], | ||||
|               defaultActiveAccessTokens[1], | ||||
|             ], | ||||
|             showRole: true, | ||||
|             backendPagination, | ||||
|           }); | ||||
| 
 | ||||
|           expect(findHeaders().at(6).text()).toBe('Action'); | ||||
|           expect(findCells().at(6).findComponent(GlButton).exists()).toBe(false); | ||||
|         }, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when backendPagination is false', () => { | ||||
|     it('sorts rows alphabetically', async () => { | ||||
|       createComponent({ showRole: true, backendPagination: false }); | ||||
| 
 | ||||
|       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, backendPagination: false }); | ||||
| 
 | ||||
|       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'); | ||||
|     }); | ||||
| 
 | ||||
|     describe('pagination', () => { | ||||
|       it('does not show pagination component', () => { | ||||
|         createComponent({ | ||||
|           initialActiveAccessTokens: [ | ||||
|             defaultActiveAccessTokens.map((data) => ({ ...data, ...input }))[0], | ||||
|             defaultActiveAccessTokens[1], | ||||
|           ], | ||||
|           showRole: true, | ||||
|           initialActiveAccessTokens: Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0]), | ||||
|           backendPagination: false, | ||||
|         }); | ||||
| 
 | ||||
|         expect(findHeaders().at(6).text()).toBe('Action'); | ||||
|         expect(findCells().at(6).findComponent(GlButton).exists()).toBe(false); | ||||
|       }, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('sorts rows alphabetically', async () => { | ||||
|     createComponent({ showRole: true }); | ||||
| 
 | ||||
|     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 }); | ||||
| 
 | ||||
|     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'); | ||||
|   }); | ||||
| 
 | ||||
|   describe('pagination', () => { | ||||
|     it('does not show pagination component', () => { | ||||
|       createComponent({ | ||||
|         initialActiveAccessTokens: Array(PAGE_SIZE).fill(defaultActiveAccessTokens[0]), | ||||
|         expect(findPagination().exists()).toBe(false); | ||||
|       }); | ||||
| 
 | ||||
|       expect(findPagination().exists()).toBe(false); | ||||
|       describe('when number of tokens exceeds the first page', () => { | ||||
|         beforeEach(() => { | ||||
|           createComponent({ | ||||
|             initialActiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0]), | ||||
|             backendPagination: false, | ||||
|           }); | ||||
|         }); | ||||
| 
 | ||||
|         it('shows the pagination component', () => { | ||||
|           expect(findPagination().exists()).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         describe('when clicked on the second page', () => { | ||||
|           it('shows only one token in the table', async () => { | ||||
|             expect(findCells()).toHaveLength(PAGE_SIZE * 6); | ||||
|             await findPagination().vm.$emit('input', 2); | ||||
|             await nextTick(); | ||||
| 
 | ||||
|             expect(findCells()).toHaveLength(6); | ||||
|           }); | ||||
| 
 | ||||
|           it('scrolls to the top', async () => { | ||||
|             const scrollToSpy = jest.spyOn(window, 'scrollTo'); | ||||
|             await findPagination().vm.$emit('input', 2); | ||||
|             await nextTick(); | ||||
| 
 | ||||
|             expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 }); | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when backendPagination is true', () => { | ||||
|     beforeEach(() => { | ||||
|       createComponent({ showRole: true, backendPagination: true }); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows the pagination component', () => { | ||||
|       createComponent({ | ||||
|         initialActiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultActiveAccessTokens[0]), | ||||
|     it('does not sort rows alphabetically', async () => { | ||||
|       // await axios.waitForAll();
 | ||||
|       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 are not swapped
 | ||||
|       expect(cells.at(0).text()).toBe('a'); | ||||
|       expect(cells.at(7).text()).toBe('b'); | ||||
|     }); | ||||
| 
 | ||||
|     it('change the busy state in the table', async () => { | ||||
|       expect(findTable().attributes('aria-busy')).toBe('true'); | ||||
| 
 | ||||
|       await axios.waitForAll(); | ||||
| 
 | ||||
|       expect(findTable().attributes('aria-busy')).toBe('false'); | ||||
|     }); | ||||
| 
 | ||||
|     describe('when a new token is created', () => { | ||||
|       it('replaces the window history', async () => { | ||||
|         const replaceStateSpy = jest.spyOn(window.history, 'replaceState'); | ||||
|         await triggerSuccess(); | ||||
| 
 | ||||
|         expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '?page=1'); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('pagination', () => { | ||||
|       it('does not show pagination component', async () => { | ||||
|         await axios.waitForAll(); | ||||
| 
 | ||||
|         expect(findPagination().exists()).toBe(false); | ||||
|       }); | ||||
| 
 | ||||
|       describe('when number of tokens exceeds the first page', () => { | ||||
|         beforeEach(() => { | ||||
|           const accessTokens = Array(21).fill(defaultActiveAccessTokens[0]); | ||||
| 
 | ||||
|           const headers = { | ||||
|             'X-Page': 1, | ||||
|             'X-Per-Page': 20, | ||||
|             'X-Total': accessTokens.length, | ||||
|           }; | ||||
|           mockAxios.onGet().reply( | ||||
|             HTTP_STATUS_OK, | ||||
|             [ | ||||
|               { | ||||
|                 active_access_tokens: accessTokens, | ||||
|                 total: accessTokens.length, | ||||
|               }, | ||||
|             ], | ||||
|             headers, | ||||
|           ); | ||||
|           createComponent({ initialActiveAccessTokens: accessTokens, backendPagination: true }); | ||||
|         }); | ||||
| 
 | ||||
|         it('shows the pagination component', async () => { | ||||
|           await axios.waitForAll(); | ||||
| 
 | ||||
|           expect(findPagination().exists()).toBe(true); | ||||
|         }); | ||||
| 
 | ||||
|         describe('when clicked on the second page', () => { | ||||
|           it('replace the window history', async () => { | ||||
|             await axios.waitForAll(); | ||||
| 
 | ||||
|             const replaceStateSpy = jest.spyOn(window.history, 'replaceState'); | ||||
|             await findPagination().vm.$emit('input', 2); | ||||
|             await axios.waitForAll(); | ||||
| 
 | ||||
|             expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '?page=2'); | ||||
|           }); | ||||
| 
 | ||||
|           it('scrolls to the top', async () => { | ||||
|             await axios.waitForAll(); | ||||
| 
 | ||||
|             const scrollToSpy = jest.spyOn(window, 'scrollTo'); | ||||
|             await findPagination().vm.$emit('input', 2); | ||||
|             await axios.waitForAll(); | ||||
| 
 | ||||
|             expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 }); | ||||
|           }); | ||||
|         }); | ||||
|       }); | ||||
|       expect(findPagination().exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -909,26 +909,6 @@ describe('Api', () => { | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('pipelineJobs', () => { | ||||
|     it.each([undefined, {}, { foo: true }])( | ||||
|       'fetches the jobs for a given pipeline given %p params', | ||||
|       async (params) => { | ||||
|         const projectId = 123; | ||||
|         const pipelineId = 456; | ||||
|         const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`; | ||||
|         const payload = [ | ||||
|           { | ||||
|             name: 'test', | ||||
|           }, | ||||
|         ]; | ||||
|         mock.onGet(expectedUrl, { params }).reply(HTTP_STATUS_OK, payload); | ||||
| 
 | ||||
|         const { data } = await Api.pipelineJobs(projectId, pipelineId, params); | ||||
|         expect(data).toEqual(payload); | ||||
|       }, | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   describe('createBranch', () => { | ||||
|     it('creates new branch', () => { | ||||
|       const ref = 'main'; | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ RSpec.describe Types::MemberInterface do | |||
|       access_level | ||||
|       created_by | ||||
|       created_at | ||||
|       last_activity_on | ||||
|       updated_at | ||||
|       expires_at | ||||
|       user | ||||
|  | @ -18,6 +19,8 @@ RSpec.describe Types::MemberInterface do | |||
|     expect(described_class).to have_graphql_fields(*expected_fields) | ||||
|   end | ||||
| 
 | ||||
|   it { expect(described_class.fields['lastActivityOn']).to have_graphql_type(Types::TimeType) } | ||||
| 
 | ||||
|   describe '.resolve_type' do | ||||
|     subject { described_class.resolve_type(object, {}) } | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,51 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe Gitlab::BackgroundMigration::BackfillMembersRequestAcceptedAt, schema: 20240920083708, feature_category: :groups_and_projects do | ||||
|   let!(:namespace) { table(:namespaces).create!({ name: "test-1", path: "test-1", owner_id: 1 }) } | ||||
|   let!(:member) { table(:members) } | ||||
|   let!(:member_data) do | ||||
|     { | ||||
|       access_level: ::Gitlab::Access::MAINTAINER, | ||||
|       member_namespace_id: namespace.id, | ||||
|       notification_level: 3, | ||||
|       source_type: "Namespace", | ||||
|       source_id: 22, | ||||
|       created_at: "2024-09-14 06:06:16.649264" | ||||
|     } | ||||
|   end | ||||
| 
 | ||||
|   let!(:member1) { member.create!(member_data) } | ||||
|   let!(:member2) { member.create!(member_data) } | ||||
|   let!(:member3) { member.create!(member_data.merge(requested_at: Time.current)) } | ||||
|   let!(:member4) { member.create!(member_data.merge(invite_token: 'token')) } | ||||
|   let!(:member5) { member.create!(member_data.merge(request_accepted_at: Time.current)) } | ||||
|   let!(:member6) { member.create!(member_data.merge(invite_accepted_at: Time.current)) } | ||||
| 
 | ||||
|   subject(:migration) do | ||||
|     described_class.new( | ||||
|       start_id: member1.id, | ||||
|       end_id: member6.id, | ||||
|       batch_table: :members, | ||||
|       batch_column: :id, | ||||
|       sub_batch_size: 100, | ||||
|       pause_ms: 0, | ||||
|       connection: ApplicationRecord.connection | ||||
|     ) | ||||
|   end | ||||
| 
 | ||||
|   describe '#perform' do | ||||
|     context 'when `requested_at`, `invite_token`, `invite_accepted_at` and `request_accepted_at` are set to nil' do | ||||
|       it 'backfills `request_accepted_at` column to `created_at` for eligible members' do | ||||
|         expect { migration.perform } | ||||
|           .to change { member1.reload.request_accepted_at }.from(nil).to(member1.created_at) | ||||
|           .and change { member2.reload.request_accepted_at }.from(nil).to(member2.created_at) | ||||
|           .and not_change { member3.reload.request_accepted_at } | ||||
|           .and not_change { member4.reload.request_accepted_at } | ||||
|           .and not_change { member5.reload.request_accepted_at } | ||||
|           .and not_change { member6.reload.request_accepted_at } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,26 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| require_migration! | ||||
| 
 | ||||
| RSpec.describe QueueBackfillMembersRequestAcceptedAt, feature_category: :groups_and_projects do | ||||
|   let!(:batched_migration) { described_class::MIGRATION } | ||||
| 
 | ||||
|   it 'schedules a new batched migration' do | ||||
|     reversible_migration do |migration| | ||||
|       migration.before -> { | ||||
|         expect(batched_migration).not_to have_scheduled_batched_migration | ||||
|       } | ||||
| 
 | ||||
|       migration.after -> { | ||||
|         expect(batched_migration).to have_scheduled_batched_migration( | ||||
|           table_name: :members, | ||||
|           column_name: :id, | ||||
|           interval: described_class::DELAY_INTERVAL, | ||||
|           batch_size: described_class::BATCH_SIZE, | ||||
|           sub_batch_size: described_class::SUB_BATCH_SIZE | ||||
|         ) | ||||
|       } | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -51,6 +51,7 @@ RSpec.describe Admin::ImpersonationTokensController, :enable_admin_mode, feature | |||
|   describe "#create", :with_current_organization do | ||||
|     it_behaves_like "#create access token" do | ||||
|       let(:url) { admin_user_impersonation_tokens_path(user_id: user.username) } | ||||
|       let(:token_attributes) { attributes_for(:personal_access_token, impersonation: true) } | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ RSpec.describe 'GroupMember', feature_category: :groups_and_projects do | |||
|           integerValue | ||||
|           stringValue | ||||
|         } | ||||
|         lastActivityOn | ||||
|         group { | ||||
|           id | ||||
|         } | ||||
|  | @ -30,4 +31,6 @@ RSpec.describe 'GroupMember', feature_category: :groups_and_projects do | |||
| 
 | ||||
|   it_behaves_like 'a working graphql query' | ||||
|   it_behaves_like 'a working membership object query' | ||||
| 
 | ||||
|   it { expect(graphql_data.dig('user', 'groupMemberships', 'nodes', 0, 'lastActivityOn')).to be_present } | ||||
| end | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ RSpec.describe 'ProjectMember', feature_category: :groups_and_projects do | |||
|           integerValue | ||||
|           stringValue | ||||
|         } | ||||
|         lastActivityOn | ||||
|         project { | ||||
|           id | ||||
|         } | ||||
|  | @ -30,4 +31,6 @@ RSpec.describe 'ProjectMember', feature_category: :groups_and_projects do | |||
| 
 | ||||
|   it_behaves_like 'a working graphql query' | ||||
|   it_behaves_like 'a working membership object query' | ||||
| 
 | ||||
|   it { expect(graphql_data.dig('user', 'projectMemberships', 'nodes', 0, 'lastActivityOn')).to be_present } | ||||
| end | ||||
|  |  | |||
|  | @ -196,6 +196,8 @@ RSpec.shared_examples '#create access token' do | |||
| 
 | ||||
|       parsed_body = Gitlab::Json.parse(response.body) | ||||
|       expect(parsed_body['new_token']).not_to be_blank | ||||
|       expect(parsed_body['active_access_tokens'].length).to be > 0 | ||||
|       expect(parsed_body['total']).to be > 0 | ||||
|       expect(parsed_body['errors']).to be_blank | ||||
|       expect(response).to have_gitlab_http_status(:success) | ||||
|     end | ||||
|  |  | |||
|  | @ -132,6 +132,8 @@ RSpec.shared_examples 'POST resource access tokens available' do | |||
| 
 | ||||
|     parsed_body = Gitlab::Json.parse(response.body) | ||||
|     expect(parsed_body['new_token']).not_to be_blank | ||||
|     expect(parsed_body['active_access_tokens'].length).to be > 0 | ||||
|     expect(parsed_body['total']).to be > 0 | ||||
|     expect(parsed_body['errors']).to be_blank | ||||
|     expect(response).to have_gitlab_http_status(:success) | ||||
|   end | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue