Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-05-23 12:08:01 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-05-23 12:08:01 +0300
commitc71c2ba4c29ed3cc483e528a32f34816c98c39f4 (patch)
tree56a8a8355631b9d58544bb74816082529ce0044d /spec
parent5534414cd55a3e85d8f60e1a0883ed32f190df6b (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/profiles/personal_access_tokens_controller_spec.rb65
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb159
-rw-r--r--spec/features/projects/settings/access_tokens_spec.rb2
-rw-r--r--spec/frontend/access_tokens/components/access_token_table_app_spec.js228
-rw-r--r--spec/frontend/access_tokens/components/new_access_token_app_spec.js161
-rw-r--r--spec/frontend/access_tokens/index_spec.js214
-rw-r--r--spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js28
-rw-r--r--spec/graphql/resolvers/design_management/versions_resolver_spec.rb2
-rw-r--r--spec/lib/api/entities/personal_access_token_with_details_spec.rb29
-rw-r--r--spec/lib/gitlab/graphql/markdown_field_spec.rb4
-rw-r--r--spec/support/helpers/countries_controller_test_helper.rb9
-rw-r--r--spec/support/helpers/gitaly_setup.rb10
-rw-r--r--spec/support/shared_examples/features/access_tokens_shared_examples.rb2
13 files changed, 833 insertions, 80 deletions
diff --git a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
index 3859af66292..6b852daeda2 100644
--- a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
+++ b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
@@ -39,30 +39,19 @@ RSpec.describe Profiles::PersonalAccessTokensController do
describe '#index' do
let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
- let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
- let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) }
- let(:token_value) { 's3cr3t' }
before do
- PersonalAccessToken.redis_store!(user.id, token_value)
+ # Impersonation and inactive personal tokens are ignored
+ create(:personal_access_token, :impersonation, user: user)
+ create(:personal_access_token, :revoked, user: user)
get :index
end
- it "retrieves active personal access tokens" do
- expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token)
- end
-
- it "retrieves inactive personal access tokens" do
- expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token)
- end
-
- it "does not retrieve impersonation personal access tokens" do
- expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token)
- expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token)
- end
+ it "only includes details of the active personal access token" do
+ active_personal_access_tokens_detail = ::API::Entities::PersonalAccessTokenWithDetails
+ .represent([active_personal_access_token])
- it "retrieves newly created personal access token value" do
- expect(assigns(:new_personal_access_token)).to eql(token_value)
+ expect(assigns(:active_personal_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json)
end
it "sets PAT name and scopes" do
@@ -77,4 +66,44 @@ RSpec.describe Profiles::PersonalAccessTokensController do
)
end
end
+
+ context 'access_token_ajax feature flag disabled' do
+ before do
+ stub_feature_flags(access_token_ajax: false)
+ PersonalAccessToken.redis_store!(user.id, token_value)
+ get :index
+ end
+
+ describe '#index' do
+ let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
+ let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
+ let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) }
+ let(:token_value) { 's3cr3t' }
+
+ it "retrieves active personal access tokens" do
+ expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token)
+ end
+
+ it "does not retrieve impersonation tokens or inactive personal access tokens" do
+ expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token)
+ expect(assigns(:active_personal_access_tokens)).not_to include(inactive_personal_access_token)
+ end
+
+ it "retrieves newly created personal access token value" do
+ expect(assigns(:new_personal_access_token)).to eql(token_value)
+ end
+
+ it "sets PAT name and scopes" do
+ name = 'My PAT'
+ scopes = 'api,read_user'
+
+ get :index, params: { name: name, scopes: scopes }
+
+ expect(assigns(:personal_access_token)).to have_attributes(
+ name: eq(name),
+ scopes: contain_exactly(:api, :read_user)
+ )
+ end
+ end
+ end
end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 8cbc0491441..01e2571ff3e 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -7,28 +7,17 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) }
def active_personal_access_tokens
- find(".table.active-tokens")
- end
-
- def no_personal_access_tokens_message
- find(".settings-message")
+ find("[data-testid='active-tokens']")
end
def created_personal_access_token
- find("#created-personal-access-token").value
+ find("[data-testid='new-access-token'] input").value
end
def feed_token_description
"Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs."
end
- def disallow_personal_access_token_saves!
- allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service)
-
- errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
- allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
- end
-
before do
stub_feature_flags(bootstrap_confirmation_modals: false)
sign_in(user)
@@ -51,6 +40,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
check "read_user"
click_on "Create personal access token"
+ wait_for_all_requests
expect(active_personal_access_tokens).to have_text(name)
expect(active_personal_access_tokens).to have_text('in')
@@ -61,13 +51,16 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
context "when creation fails" do
it "displays an error message" do
- disallow_personal_access_token_saves!
+ number_tokens_before = PersonalAccessToken.count
visit profile_personal_access_tokens_path
fill_in "Token name", with: 'My PAT'
- expect { click_on "Create personal access token" }.not_to change { PersonalAccessToken.count }
- expect(page).to have_content("Name cannot be nil")
- expect(page).not_to have_selector("#created-personal-access-token")
+ click_on "Create personal access token"
+ wait_for_all_requests
+
+ expect(number_tokens_before).to equal(PersonalAccessToken.count)
+ expect(page).to have_content(_("Scopes can't be blank"))
+ expect(page).not_to have_selector("[data-testid='new-access-tokens']")
end
end
end
@@ -103,29 +96,25 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
visit profile_personal_access_tokens_path
accept_confirm { click_on "Revoke" }
- expect(page).to have_selector(".settings-message")
- expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
+ expect(active_personal_access_tokens).to have_text("This user has no active personal access tokens.")
end
it "removes expired tokens from 'active' section" do
personal_access_token.update!(expires_at: 5.days.ago)
visit profile_personal_access_tokens_path
- expect(page).to have_selector(".settings-message")
- expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
+ expect(active_personal_access_tokens).to have_text("This user has no active personal access tokens.")
end
context "when revocation fails" do
it "displays an error message" do
- visit profile_personal_access_tokens_path
-
allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance|
allow(instance).to receive(:revocation_permitted?).and_return(false)
end
+ visit profile_personal_access_tokens_path
accept_confirm { click_on "Revoke" }
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
- expect(page).to have_content("Not permitted to revoke")
end
end
end
@@ -172,4 +161,126 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
expect(find("#personal_access_token_scopes_api")).to be_checked
expect(find("#personal_access_token_scopes_read_user")).to be_checked
end
+
+ context 'access_token_ajax feature flag disabled' do
+ def active_personal_access_tokens
+ find(".table.active-tokens")
+ end
+
+ def no_personal_access_tokens_message
+ find(".settings-message")
+ end
+
+ def created_personal_access_token
+ find("#created-personal-access-token").value
+ end
+
+ def disallow_personal_access_token_saves!
+ allow_next_instance_of(PersonalAccessToken) do |pat|
+ pat.errors.add(:name, 'cannot be nil')
+ end
+
+ allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service)
+ end
+
+ before do
+ stub_feature_flags(bootstrap_confirmation_modals: false)
+ stub_feature_flags(access_token_ajax: false)
+ sign_in(user)
+ end
+
+ describe "token creation" do
+ it "allows creation of a personal access token" do
+ name = 'My PAT'
+
+ visit profile_personal_access_tokens_path
+ fill_in "Token name", with: name
+
+ # Set date to 1st of next month
+ find_field("Expiration date").click
+ find(".pika-next").click
+ click_on "1"
+
+ # Scopes
+ check "read_api"
+ check "read_user"
+
+ click_on "Create personal access token"
+
+ expect(active_personal_access_tokens).to have_text(name)
+ expect(active_personal_access_tokens).to have_text('in')
+ expect(active_personal_access_tokens).to have_text('read_api')
+ expect(active_personal_access_tokens).to have_text('read_user')
+ expect(created_personal_access_token).not_to be_empty
+ end
+
+ context "when creation fails" do
+ it "displays an error message" do
+ disallow_personal_access_token_saves!
+ visit profile_personal_access_tokens_path
+ fill_in "Token name", with: 'My PAT'
+
+ expect { click_on "Create personal access token" }.not_to change { PersonalAccessToken.count }
+ expect(page).to have_content("Name cannot be nil")
+ expect(page).not_to have_selector("#created-personal-access-token")
+ end
+ end
+ end
+
+ describe 'active tokens' do
+ let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) }
+ let!(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ it 'only shows personal access tokens' do
+ visit profile_personal_access_tokens_path
+
+ expect(active_personal_access_tokens).to have_text(personal_access_token.name)
+ expect(active_personal_access_tokens).not_to have_text(impersonation_token.name)
+ end
+
+ context 'when User#time_display_relative is false' do
+ before do
+ user.update!(time_display_relative: false)
+ end
+
+ it 'shows absolute times for expires_at' do
+ visit profile_personal_access_tokens_path
+
+ expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d'))
+ end
+ end
+ end
+
+ describe "inactive tokens" do
+ let!(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ it "allows revocation of an active token" do
+ visit profile_personal_access_tokens_path
+ accept_confirm { click_on "Revoke" }
+
+ expect(page).to have_selector(".settings-message")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
+ end
+
+ it "removes expired tokens from 'active' section" do
+ personal_access_token.update!(expires_at: 5.days.ago)
+ visit profile_personal_access_tokens_path
+
+ expect(page).to have_selector(".settings-message")
+ expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
+ end
+
+ context "when revocation fails" do
+ it "displays an error message" do
+ allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance|
+ allow(instance).to receive(:revocation_permitted?).and_return(false)
+ end
+ visit profile_personal_access_tokens_path
+
+ accept_confirm { click_on "Revoke" }
+ expect(active_personal_access_tokens).to have_text(personal_access_token.name)
+ end
+ end
+ end
+ end
end
diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb
index 122bf267021..4bc543e080a 100644
--- a/spec/features/projects/settings/access_tokens_spec.rb
+++ b/spec/features/projects/settings/access_tokens_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do
it 'shows access token creation form and text' do
visit project_settings_access_tokens_path(personal_project)
- expect(page).to have_selector('#new_resource_access_token')
+ expect(page).to have_selector('#js-new-access-token-form')
expect(page).to have_text('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.')
end
end
diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
new file mode 100644
index 00000000000..827bc1a6a4d
--- /dev/null
+++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
@@ -0,0 +1,228 @@
+import { GlTable } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
+import { __, s__, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+
+describe('~/access_tokens/components/access_token_table_app', () => {
+ let wrapper;
+
+ const accessTokenType = 'personal access token';
+ const accessTokenTypePlural = 'personal access tokens';
+ const initialActiveAccessTokens = [];
+ const noActiveTokensMessage = 'This user has no active personal access tokens.';
+ const showRole = false;
+
+ const defaultActiveAccessTokens = [
+ {
+ name: 'a',
+ scopes: ['api'],
+ created_at: '2021-05-01T00:00:00.000Z',
+ last_used_at: null,
+ expired: false,
+ expires_soon: true,
+ expires_at: null,
+ revoked: false,
+ revoke_path: '/-/profile/personal_access_tokens/1/revoke',
+ role: 'Maintainer',
+ },
+ {
+ name: 'b',
+ scopes: ['api', 'sudo'],
+ created_at: '2022-04-21T00:00:00.000Z',
+ last_used_at: '2022-04-21T00:00:00.000Z',
+ expired: true,
+ expires_soon: false,
+ expires_at: new Date().toISOString(),
+ revoked: false,
+ revoke_path: '/-/profile/personal_access_tokens/2/revoke',
+ role: 'Maintainer',
+ },
+ ];
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(AccessTokenTableApp, {
+ provide: {
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+ noActiveTokensMessage,
+ showRole,
+ ...props,
+ },
+ });
+ };
+
+ const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => {
+ wrapper
+ .findComponent(DomElementListener)
+ .vm.$emit('ajax:success', { detail: [{ active_access_tokens: activeAccessTokens }] });
+ await nextTick();
+ };
+
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findHeaders = () => findTable().findAll('th > :first-child');
+ const findCells = () => findTable().findAll('td');
+
+ afterEach(() => {
+ wrapper?.destroy();
+ });
+
+ it('should render the `GlTable` with default empty message', () => {
+ createComponent();
+
+ const cells = findCells();
+ expect(cells).toHaveLength(1);
+ expect(cells.at(0).text()).toBe(
+ sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }),
+ );
+ });
+
+ it('should render the `GlTable` with custom empty message', () => {
+ const noTokensMessage = 'This group has no active access tokens.';
+ createComponent({ noActiveTokensMessage: noTokensMessage });
+
+ const cells = findCells();
+ expect(cells).toHaveLength(1);
+ expect(cells.at(0).text()).toBe(noTokensMessage);
+ });
+
+ it('should render an h5 element', () => {
+ createComponent();
+
+ expect(wrapper.find('h5').text()).toBe(
+ sprintf(__('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), {
+ accessTokenTypePlural,
+ totalAccessTokens: initialActiveAccessTokens.length,
+ }),
+ );
+ });
+
+ it('should render the `GlTable` component with default 6 column headers', () => {
+ createComponent();
+
+ const headers = findHeaders();
+ expect(headers).toHaveLength(6);
+ [
+ __('Token name'),
+ __('Scopes'),
+ s__('AccessTokens|Created'),
+ __('Last Used'),
+ __('Expires'),
+ __('Action'),
+ ].forEach((text, index) => {
+ expect(headers.at(index).text()).toBe(text);
+ });
+ });
+
+ it('should render the `GlTable` component with 7 headers', () => {
+ createComponent({ showRole: true });
+
+ const headers = findHeaders();
+ expect(headers).toHaveLength(7);
+ [
+ __('Token name'),
+ __('Scopes'),
+ s__('AccessTokens|Created'),
+ __('Last Used'),
+ __('Expires'),
+ __('Role'),
+ __('Action'),
+ ].forEach((text, index) => {
+ expect(headers.at(index).text()).toBe(text);
+ });
+ });
+
+ it('`Last Used` header should contain a link and an assistive message', () => {
+ createComponent();
+
+ const headers = wrapper.findAll('th');
+ const lastUsed = headers.at(3);
+ const anchor = lastUsed.find('a');
+ const assistiveElement = lastUsed.find('.gl-sr-only');
+ expect(anchor.exists()).toBe(true);
+ expect(anchor.attributes('href')).toBe(
+ '/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used',
+ );
+ expect(assistiveElement.text()).toBe(s__('AccessTokens|The last time a token was used'));
+ });
+
+ it('updates the table after a success AJAX event', async () => {
+ createComponent({ showRole: true });
+ await triggerSuccess();
+
+ const cells = findCells();
+ expect(cells).toHaveLength(14);
+
+ // First row
+ expect(cells.at(0).text()).toBe('a');
+ expect(cells.at(1).text()).toBe('api');
+ expect(cells.at(2).text()).not.toBe(__('Never'));
+ expect(cells.at(3).text()).toBe(__('Never'));
+ expect(cells.at(4).text()).toBe(__('Never'));
+ expect(cells.at(5).text()).toBe('Maintainer');
+ let anchor = cells.at(6).find('a');
+ expect(anchor.attributes()).toMatchObject({
+ 'aria-label': __('Revoke'),
+ 'data-qa-selector': __('revoke_button'),
+ href: '/-/profile/personal_access_tokens/1/revoke',
+ 'data-confirm': sprintf(
+ __(
+ 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.',
+ ),
+ { accessTokenType },
+ ),
+ });
+
+ expect(anchor.classes()).toContain('btn-danger-secondary');
+
+ // Second row
+ expect(cells.at(7).text()).toBe('b');
+ expect(cells.at(8).text()).toBe('api, sudo');
+ expect(cells.at(9).text()).not.toBe(__('Never'));
+ expect(cells.at(10).text()).not.toBe(__('Never'));
+ expect(cells.at(11).text()).toBe(__('Expired'));
+ expect(cells.at(12).text()).toBe('Maintainer');
+ anchor = cells.at(13).find('a');
+ expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke');
+ expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']);
+ });
+
+ it('sorts rows alphabetically', async () => {
+ createComponent({ showRole: true });
+ await triggerSuccess();
+
+ const cells = findCells();
+
+ // First and second rows
+ expect(cells.at(0).text()).toBe('a');
+ expect(cells.at(7).text()).toBe('b');
+
+ const headers = findHeaders();
+ await headers.at(0).trigger('click');
+ await headers.at(0).trigger('click');
+
+ // First and second rows have swapped
+ expect(cells.at(0).text()).toBe('b');
+ expect(cells.at(7).text()).toBe('a');
+ });
+
+ it('sorts rows by date', async () => {
+ createComponent({ showRole: true });
+ await triggerSuccess();
+
+ const cells = findCells();
+
+ // First and second rows
+ expect(cells.at(3).text()).toBe('Never');
+ expect(cells.at(10).text()).not.toBe('Never');
+
+ const headers = findHeaders();
+ await headers.at(3).trigger('click');
+
+ // First and second rows have swapped
+ expect(cells.at(3).text()).not.toBe('Never');
+ expect(cells.at(10).text()).toBe('Never');
+ });
+});
diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
new file mode 100644
index 00000000000..b750a955fb2
--- /dev/null
+++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
@@ -0,0 +1,161 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
+import { createAlert, VARIANT_INFO } from '~/flash';
+import { __, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+
+jest.mock('~/flash');
+
+describe('~/access_tokens/components/new_access_token_app', () => {
+ let wrapper;
+
+ const accessTokenType = 'personal access token';
+
+ const createComponent = (provide = { accessTokenType }) => {
+ wrapper = shallowMount(NewAccessTokenApp, {
+ provide,
+ });
+ };
+
+ const triggerSuccess = async (newToken = 'new token') => {
+ wrapper
+ .find(DomElementListener)
+ .vm.$emit('ajax:success', { detail: [{ new_token: newToken }] });
+ await nextTick();
+ };
+
+ const triggerError = async (errors = ['1', '2']) => {
+ wrapper.find(DomElementListener).vm.$emit('ajax:error', { detail: [{ errors }] });
+ await nextTick();
+ };
+
+ beforeEach(() => {
+ // NewAccessTokenApp observes a form element
+ setHTMLFixture('<form id="js-new-access-token-form"><input type="submit"/></form>');
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ wrapper.destroy();
+ createAlert.mockClear();
+ });
+
+ it('should render nothing', () => {
+ expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false);
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
+ });
+
+ describe('on success', () => {
+ it('should render `InputCopyToggleVisibility` component', async () => {
+ const newToken = '12345';
+ await triggerSuccess(newToken);
+
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
+
+ const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility);
+ expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken);
+ expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe(
+ sprintf(__('Copy %{accessTokenType}'), { accessTokenType }),
+ );
+ expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true);
+ expect(InputCopyToggleVisibilityComponent.props('inputClass')).toBe(
+ 'qa-created-access-token',
+ );
+ expect(InputCopyToggleVisibilityComponent.props('qaSelector')).toBe(
+ 'created_access_token_field',
+ );
+ expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe(
+ sprintf(__('Your new %{accessTokenType}'), { accessTokenType }),
+ );
+ });
+
+ it('should render an info alert', async () => {
+ await triggerSuccess();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: sprintf(__('Your new %{accessTokenType} has been created.'), {
+ accessTokenType,
+ }),
+ variant: VARIANT_INFO,
+ });
+ });
+
+ it('should reset the form', async () => {
+ const resetSpy = jest.spyOn(wrapper.vm.form, 'reset');
+
+ await triggerSuccess();
+
+ expect(resetSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('on error', () => {
+ it('should render an error alert', async () => {
+ await triggerError(['first', 'second']);
+
+ expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false);
+
+ let GlAlertComponent = wrapper.findComponent(GlAlert);
+ expect(GlAlertComponent.props('title')).toBe(__('The form contains the following errors:'));
+ expect(GlAlertComponent.props('variant')).toBe('danger');
+ let itemEls = wrapper.findAll('li');
+ expect(itemEls).toHaveLength(2);
+ expect(itemEls.at(0).text()).toBe('first');
+ expect(itemEls.at(1).text()).toBe('second');
+
+ await triggerError(['one']);
+
+ GlAlertComponent = wrapper.findComponent(GlAlert);
+ expect(GlAlertComponent.props('title')).toBe(__('The form contains the following error:'));
+ expect(GlAlertComponent.props('variant')).toBe('danger');
+ itemEls = wrapper.findAll('li');
+ expect(itemEls).toHaveLength(1);
+ });
+
+ it('the error alert should be dismissible', async () => {
+ await triggerError();
+
+ const GlAlertComponent = wrapper.findComponent(GlAlert);
+ expect(GlAlertComponent.exists()).toBe(true);
+
+ GlAlertComponent.vm.$emit('dismiss');
+ await nextTick();
+
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
+ });
+ });
+
+ describe('before error or success', () => {
+ it('should scroll to the container', async () => {
+ const containerEl = wrapper.vm.$refs.container;
+ const scrollIntoViewSpy = jest.spyOn(containerEl, 'scrollIntoView');
+
+ await triggerSuccess();
+
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith(false);
+ expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1);
+
+ await triggerError();
+
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith(false);
+ expect(scrollIntoViewSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should dismiss the info alert', async () => {
+ const dismissSpy = jest.fn();
+ createAlert.mockReturnValue({ dismiss: dismissSpy });
+
+ await triggerSuccess();
+ await triggerError();
+
+ expect(dismissSpy).toHaveBeenCalled();
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js
index 1d8ac7cec25..b6119f1d167 100644
--- a/spec/frontend/access_tokens/index_spec.js
+++ b/spec/frontend/access_tokens/index_spec.js
@@ -1,27 +1,118 @@
+/* eslint-disable vue/require-prop-types */
+/* eslint-disable vue/one-component-per-file */
import { createWrapper } from '@vue/test-utils';
import Vue from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { initExpiresAtField, initProjectsField } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+ initProjectsField,
+ initTokensApp,
+} from '~/access_tokens';
+import * as AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
+import * as NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
import * as ProjectsField from '~/access_tokens/components/projects_field.vue';
+import * as TokensApp from '~/access_tokens/components/tokens_app.vue';
+import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants';
+import { __, sprintf } from '~/locale';
describe('access tokens', () => {
- const FakeComponent = Vue.component('FakeComponent', {
- props: {
- inputAttrs: {
- type: Object,
- required: true,
- },
- },
- render: () => null,
- });
+ let wrapper;
- beforeEach(() => {
- window.gon = { features: { personalAccessTokensScopedToProjects: true } };
+ afterEach(() => {
+ wrapper?.destroy();
+ resetHTMLFixture();
});
- afterEach(() => {
- document.body.innerHTML = '';
+ describe('initAccessTokenTableApp', () => {
+ const accessTokenType = 'personal access token';
+ const accessTokenTypePlural = 'personal access tokens';
+ const initialActiveAccessTokens = [{ id: '1' }];
+
+ const FakeAccessTokenTableApp = Vue.component('FakeComponent', {
+ inject: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ props: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ render: () => null,
+ });
+ AccessTokenTableApp.default = FakeAccessTokenTableApp;
+
+ it('mounts the component and provides required values', () => {
+ setHTMLFixture(
+ `<div id="js-access-token-table-app"
+ data-access-token-type="${accessTokenType}"
+ data-access-token-type-plural="${accessTokenTypePlural}"
+ data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)}
+ >
+ </div>`,
+ );
+
+ const vueInstance = initAccessTokenTableApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeAccessTokenTableApp);
+
+ expect(component.exists()).toBe(true);
+
+ expect(component.props()).toMatchObject({
+ // Required value
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+
+ // Default values
+ noActiveTokensMessage: sprintf(__('This user has no active %{accessTokenTypePlural}.'), {
+ accessTokenTypePlural,
+ }),
+ showRole: false,
+ });
+ });
+
+ it('mounts the component and provides all values', () => {
+ const noActiveTokensMessage = 'This group has no active access tokens.';
+ setHTMLFixture(
+ `<div id="js-access-token-table-app"
+ data-access-token-type="${accessTokenType}"
+ data-access-token-type-plural="${accessTokenTypePlural}"
+ data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)}
+ data-no-active-tokens-message="${noActiveTokensMessage}"
+ data-show-role
+ >
+ </div>`,
+ );
+
+ const vueInstance = initAccessTokenTableApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeAccessTokenTableApp);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props()).toMatchObject({
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+ noActiveTokensMessage,
+ showRole: true,
+ });
+ });
+
+ it('returns `null`', () => {
+ expect(initNewAccessTokenApp()).toBe(null);
+ });
});
describe.each`
@@ -30,33 +121,42 @@ describe('access tokens', () => {
${initProjectsField} | ${'js-access-tokens-projects'} | ${'projects'} | ${ProjectsField}
`('$initFunction', ({ initFunction, mountSelector, fieldName, expectedComponent }) => {
describe('when mount element exists', () => {
+ const FakeComponent = Vue.component('FakeComponent', {
+ props: ['inputAttrs'],
+ render: () => null,
+ });
+
const nameAttribute = `access_tokens[${fieldName}]`;
const idAttribute = `access_tokens_${fieldName}`;
beforeEach(() => {
- const mountEl = document.createElement('div');
- mountEl.classList.add(mountSelector);
-
- const input = document.createElement('input');
- input.setAttribute('name', nameAttribute);
- input.setAttribute('data-js-name', fieldName);
- input.setAttribute('id', idAttribute);
- input.setAttribute('placeholder', 'Foo bar');
- input.setAttribute('value', '1,2');
+ window.gon = { features: { personalAccessTokensScopedToProjects: true } };
- mountEl.appendChild(input);
-
- document.body.appendChild(mountEl);
+ setHTMLFixture(
+ `<div class="${mountSelector}">
+ <input
+ name="${nameAttribute}"
+ data-js-name="${fieldName}"
+ id="${idAttribute}"
+ placeholder="Foo bar"
+ value="1,2"
+ />
+ </div>`,
+ );
// Mock component so we don't have to deal with mocking Apollo
// eslint-disable-next-line no-param-reassign
expectedComponent.default = FakeComponent;
});
+ afterEach(() => {
+ delete window.gon;
+ });
+
it('mounts component and sets `inputAttrs` prop', async () => {
const vueInstance = await initFunction();
- const wrapper = createWrapper(vueInstance);
+ wrapper = createWrapper(vueInstance);
const component = wrapper.findComponent(FakeComponent);
expect(component.exists()).toBe(true);
@@ -75,4 +175,64 @@ describe('access tokens', () => {
});
});
});
+
+ describe('initNewAccessTokenApp', () => {
+ it('mounts the component and sets `accessTokenType` prop', () => {
+ const accessTokenType = 'personal access token';
+ setHTMLFixture(
+ `<div id="js-new-access-token-app" data-access-token-type="${accessTokenType}"></div>`,
+ );
+
+ const FakeNewAccessTokenApp = Vue.component('FakeComponent', {
+ inject: ['accessTokenType'],
+ props: ['accessTokenType'],
+ render: () => null,
+ });
+ NewAccessTokenApp.default = FakeNewAccessTokenApp;
+
+ const vueInstance = initNewAccessTokenApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeNewAccessTokenApp);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props('accessTokenType')).toEqual(accessTokenType);
+ });
+
+ it('returns `null`', () => {
+ expect(initNewAccessTokenApp()).toBe(null);
+ });
+ });
+
+ describe('initTokensApp', () => {
+ it('mounts the component and provides`tokenTypes` ', () => {
+ const tokensData = {
+ [FEED_TOKEN]: FEED_TOKEN,
+ [INCOMING_EMAIL_TOKEN]: INCOMING_EMAIL_TOKEN,
+ [STATIC_OBJECT_TOKEN]: STATIC_OBJECT_TOKEN,
+ };
+ setHTMLFixture(
+ `<div id="js-tokens-app" data-tokens-data=${JSON.stringify(tokensData)}></div>`,
+ );
+
+ const FakeTokensApp = Vue.component('FakeComponent', {
+ inject: ['tokenTypes'],
+ props: ['tokenTypes'],
+ render: () => null,
+ });
+ TokensApp.default = FakeTokensApp;
+
+ const vueInstance = initTokensApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeTokensApp);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props('tokenTypes')).toEqual(tokensData);
+ });
+
+ it('returns `null`', () => {
+ expect(initNewAccessTokenApp()).toBe(null);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
index e636f58d868..6d176e1bf6a 100644
--- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
+++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
@@ -236,4 +236,32 @@ describe('InputCopyToggleVisibility', () => {
expect(wrapper.findByText(description).exists()).toBe(true);
});
+
+ it('passes `inputClass` prop to `GlFormInputGroup`', () => {
+ createComponent();
+ expect(findFormInputGroup().props('inputClass')).toBe('gl-font-monospace! gl-cursor-default!');
+ wrapper.destroy();
+
+ createComponent({
+ propsData: {
+ inputClass: 'Foo bar',
+ },
+ });
+ expect(findFormInputGroup().props('inputClass')).toBe(
+ 'gl-font-monospace! gl-cursor-default! Foo bar',
+ );
+ });
+
+ it('passes `qaSelector` prop as an `data-qa-selector` attribute to `GlFormInputGroup`', () => {
+ createComponent();
+ expect(findFormInputGroup().attributes('data-qa-selector')).toBeUndefined();
+ wrapper.destroy();
+
+ createComponent({
+ propsData: {
+ qaSelector: 'Foo bar',
+ },
+ });
+ expect(findFormInputGroup().attributes('data-qa-selector')).toBe('Foo bar');
+ });
});
diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
index 8eab0222cf6..67e23c05a67 100644
--- a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
@@ -90,6 +90,8 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do
end
context 'and they do not match' do
+ subject(:result) { resolve_versions(object) }
+
let(:params) do
{
earlier_or_equal_to_sha: first_version.sha,
diff --git a/spec/lib/api/entities/personal_access_token_with_details_spec.rb b/spec/lib/api/entities/personal_access_token_with_details_spec.rb
new file mode 100644
index 00000000000..a53d6febba1
--- /dev/null
+++ b/spec/lib/api/entities/personal_access_token_with_details_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::PersonalAccessTokenWithDetails do
+ describe '#as_json' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:token) { create(:personal_access_token, user: user, expires_at: nil) }
+
+ let(:entity) { described_class.new(token) }
+
+ it 'returns token data' do
+ expect(entity.as_json).to eq({
+ id: token.id,
+ name: token.name,
+ revoked: false,
+ created_at: token.created_at,
+ scopes: ['api'],
+ user_id: user.id,
+ last_used_at: nil,
+ active: true,
+ expires_at: nil,
+ expired: false,
+ expires_soon: false,
+ revoke_path: Gitlab::Routing.url_helpers.revoke_profile_personal_access_token_path(token)
+ })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/markdown_field_spec.rb b/spec/lib/gitlab/graphql/markdown_field_spec.rb
index ed3f19d8cf2..84494d3dd68 100644
--- a/spec/lib/gitlab/graphql/markdown_field_spec.rb
+++ b/spec/lib/gitlab/graphql/markdown_field_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Graphql::MarkdownField do
include Gitlab::Routing
+ include GraphqlHelpers
describe '.markdown_field' do
it 'creates the field with some default attributes' do
@@ -33,8 +34,7 @@ RSpec.describe Gitlab::Graphql::MarkdownField do
context 'resolving markdown' do
let_it_be(:note) { build(:note, note: '# Markdown!') }
let_it_be(:expected_markdown) { '<h1 data-sourcepos="1:1-1:11" dir="auto">Markdown!</h1>' }
- let_it_be(:query_type) { GraphQL::ObjectType.new }
- let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
+ let_it_be(:schema) { empty_schema }
let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) }
let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: {}, object: nil) }
diff --git a/spec/support/helpers/countries_controller_test_helper.rb b/spec/support/helpers/countries_controller_test_helper.rb
new file mode 100644
index 00000000000..5d36a29bba7
--- /dev/null
+++ b/spec/support/helpers/countries_controller_test_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module CountriesControllerTestHelper
+ def world_deny_list
+ ::World::DENYLIST + ::World::JH_MARKET
+ end
+end
+
+CountriesControllerTestHelper.prepend_mod
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index 264281ef94a..35fa69481a9 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -369,7 +369,7 @@ module GitalySetup
message += "- The `gitaly` binary does not exist: #{gitaly_binary}\n" unless File.exist?(gitaly_binary)
message += "- The `praefect` binary does not exist: #{praefect_binary}\n" unless File.exist?(praefect_binary)
- message += "- The `git` binary does not exist: #{git_binary}\n" unless File.exist?(git_binary)
+ message += "- No `git` binaries exist\n" if git_binaries.empty?
message += "\nCheck log/gitaly-test.log & log/praefect-test.log for errors.\n"
@@ -381,8 +381,8 @@ module GitalySetup
message
end
- def git_binary
- File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git")
+ def git_binaries
+ Dir.glob(File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git-v*"))
end
def gitaly_binary
@@ -392,8 +392,4 @@ module GitalySetup
def praefect_binary
File.join(tmp_tests_gitaly_dir, "_build", "bin", "praefect")
end
-
- def git_binary_exists?
- File.exist?(git_binary)
- end
end
diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
index 215d9d3e5a8..5fa96443608 100644
--- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb
+++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
@@ -51,7 +51,7 @@ RSpec.shared_examples 'resource access tokens creation disallowed' do |error_mes
it 'does not show access token creation form' do
visit resource_settings_access_tokens_path
- expect(page).not_to have_selector('#new_resource_access_token')
+ expect(page).not_to have_selector('#js-new-access-token-form')
end
it 'shows access token creation disabled text' do