Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-10-15 09:18:32 +00:00
parent c2acba9468
commit f25663fd8e
31 changed files with 644 additions and 246 deletions

View File

@ -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>

View File

@ -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,

View File

@ -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));

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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.'

View File

@ -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.')} }

View File

@ -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.')} }

View File

@ -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 } }

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
1c3c404213d4eaaa59cb40e025121787d790daf0a6e42bfd04ad7ccb657fae7d

View File

@ -0,0 +1 @@
487b9bcc614eef44330b4c5024b0b9383a6271981f436fda86a0d95b93689e70

View File

@ -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);

View File

@ -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. |

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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);
});
});
});

View File

@ -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';

View File

@ -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, {}) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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