Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-03-07 21:18:32 +00:00
parent 7fcb54624b
commit 0f6fb8a8c9
35 changed files with 1049 additions and 174 deletions

View File

@ -1,6 +1,6 @@
<!--Lightweight issue template to encourage Dogfooding and educate team members about the importance of Dogfooding -->
/label ~"dogfooding" ~"group::" ~"section::" ~"Category::"
/label ~"dogfooding" ~"group::" ~"section::" ~"Category:"
## Feature to Dogfood
<!--Link to Description of feature (Documentation, Epic, Opportunity Canvas, etc.) -->

View File

@ -4,7 +4,7 @@
<!-- What is the problem and solution you're proposing? This content sets the overall vision for the feature and serves as the release notes that will populate in various places, including the [release post blog](https://about.gitlab.com/releases/categories/releases/) and [Gitlab project releases](https://gitlab.com/gitlab-org/gitlab/-/releases). " -->
### Problem to solve
### Problem to solve
<!-- What problem do we solve? Try to define the who/what/why of the opportunity as a user story. For example, "As a (who), I want (what), so I can (why/value)." -->
@ -37,7 +37,6 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma
For example, "The user should be able to use the UI/API/.gitlab-ci.yml with GitLab to <perform a specific task>"
https://about.gitlab.com/handbook/engineering/ux/ux-research-training/user-story-mapping/ -->
### Proposal
<!-- How are we going to solve the problem? Try to include the user journey! https://about.gitlab.com/handbook/journeys/#user-journey -->
@ -55,9 +54,9 @@ Consider adding checkboxes and expectations of users with certain levels of memb
* [ ] Add expected impact to Reporter (20) members
* [ ] Add expected impact to Developer (30) members
* [ ] Add expected impact to Maintainer (40) members
* [ ] Add expected impact to Owner (50) members
* [ ] Add expected impact to Owner (50) members
Please consider performing a threat model for the code changes that are introduced as part of this feature. To get started, refer to our Threat Modeling handbook page https://about.gitlab.com/handbook/security/threat_modeling/#threat-modeling.
Please consider performing a threat model for the code changes that are introduced as part of this feature. To get started, refer to our Threat Modeling handbook page https://about.gitlab.com/handbook/security/threat_modeling/#threat-modeling.
Don't hesitate to reach out to the Application Security Team (`@gitlab-com/gl-security/appsec`) to discuss any security concerns.
-->
@ -91,7 +90,7 @@ See the test engineering planning process and reach out to your counterpart Soft
* Ultimate/Gold
-->
### Feature Usage Metrics
### Feature Usage Metrics
<!-- How are you going to track usage of this feature? Think about user behavior and their interaction with the product. What indicates someone is getting value from it?

View File

@ -2,7 +2,7 @@
The goal of this template is to ensure we have captured all the information available to the product designer so they can approach the problem creatively and efficiently. Please add links to SSOT if this informatin exists elsewhere. -->
### Who will use this solution?
### Who will use this solution?
<!-- If known, include any of the following: types of users (e.g. Developer), personas, or specific company roles (e.g. Release Manager). It's okay to write "Unknown" and fill this field in later.
@ -54,5 +54,5 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma
/label ~"group::" ~"section::" ~"Category::" ~UX
/label ~"group::" ~"section::" ~"Category:" ~UX

View File

@ -5,9 +5,6 @@ Database/MultipleDatabases:
- ee/lib/gitlab/geo/geo_tasks.rb
- ee/lib/gitlab/geo/health_check.rb
- ee/lib/gitlab/geo/log_cursor/daemon.rb
- ee/lib/pseudonymizer/dumper.rb
- ee/lib/pseudonymizer/pager.rb
- ee/spec/lib/pseudonymizer/dumper_spec.rb
- ee/spec/services/ee/merge_requests/update_service_spec.rb
- lib/backup/database.rb
- lib/backup/manager.rb

View File

@ -92,6 +92,7 @@ const Api = {
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
secureFilesPath: '/api/:version/projects/:project_id/secure_files',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@ -957,6 +958,13 @@ const Api = {
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
},
// TODO: replace this when GraphQL support has been added https://gitlab.com/gitlab-org/gitlab/-/issues/352184
projectSecureFiles(projectId, options = {}) {
const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId);
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } });
},
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);

View File

@ -0,0 +1,133 @@
<script>
import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
TimeagoTooltip,
},
inject: ['projectId'],
docsLink: helpPagePath('ci/secure_files/index'),
DEFAULT_PER_PAGE,
i18n: {
pagination: {
next: __('Next'),
prev: __('Prev'),
},
title: __('Secure Files'),
overviewMessage: __(
'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
),
moreInformation: __('More information'),
},
data() {
return {
page: 1,
totalItems: 0,
loading: false,
projectSecureFiles: [],
};
},
fields: [
{
key: 'name',
label: __('Filename'),
},
{
key: 'permissions',
label: __('Permissions'),
},
{
key: 'created_at',
label: __('Uploaded'),
},
],
computed: {
fields() {
return this.$options.fields;
},
},
watch: {
page(newPage) {
this.getProjectSecureFiles(newPage);
},
},
created() {
this.getProjectSecureFiles();
},
methods: {
async getProjectSecureFiles(page) {
this.loading = true;
const response = await Api.projectSecureFiles(this.projectId, { page });
this.totalItems = parseInt(response.headers?.['x-total'], 10) || 0;
this.projectSecureFiles = response.data;
this.loading = false;
},
},
};
</script>
<template>
<div>
<h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1>
<p>
<span data-testid="info-message" class="gl-mr-2">
{{ $options.i18n.overviewMessage }}
<gl-link :href="$options.docsLink" target="_blank">{{
$options.i18n.moreInformation
}}</gl-link>
</span>
</p>
<gl-table
:busy="loading"
:fields="fields"
:items="projectSecureFiles"
tbody-tr-class="js-ci-secure-files-row"
data-qa-selector="ci_secure_files_table_content"
sort-by="key"
sort-direction="asc"
stacked="lg"
table-class="text-secondary"
show-empty
sort-icon-left
no-sort-reset
>
<template #table-busy>
<gl-loading-icon size="lg" class="gl-my-5" />
</template>
<template #cell(name)="{ item }">
{{ item.name }}
</template>
<template #cell(permissions)="{ item }">
{{ item.permissions }}
</template>
<template #cell(created_at)="{ item }">
<timeago-tooltip :time="item.created_at" />
</template>
</gl-table>
<gl-pagination
v-if="!loading"
v-model="page"
:per-page="$options.DEFAULT_PER_PAGE"
:total-items="totalItems"
:next-text="$options.i18n.pagination.next"
:prev-text="$options.i18n.pagination.prev"
align="center"
/>
</div>
</template>

View File

@ -0,0 +1,17 @@
import Vue from 'vue';
import SecureFilesList from './components/secure_files_list.vue';
export const initCiSecureFiles = (selector = '#js-ci-secure-files') => {
const containerEl = document.querySelector(selector);
const { projectId } = containerEl.dataset;
return new Vue({
el: containerEl,
provide: {
projectId,
},
render(createElement) {
return createElement(SecureFilesList);
},
});
};

View File

@ -2,6 +2,7 @@ import $ from 'jquery';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { __ } from '~/locale';
@ -14,7 +15,7 @@ export default class GpgBadges {
const badges = $('.js-loading-gpg-badge');
badges.html('<span class="gl-spinner gl-spinner-orange gl-spinner-sm"></span>');
badges.html(loadingIconForLegacyJS());
badges.children().attr('aria-label', __('Loading'));
const displayError = () =>

View File

@ -0,0 +1,3 @@
import { initCiSecureFiles } from '~/ci_secure_files';
initCiSecureFiles();

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Projects::Ci::SecureFilesController < Projects::ApplicationController
before_action :check_can_collaborate!
feature_category :pipeline_authoring
def show
end
private
def check_can_collaborate!
render_404 unless can_collaborate_with_project?(project)
end
end

View File

@ -49,7 +49,11 @@ module Types
# Construct a restricted type, that can only be inhabited by an ID of
# a given model class.
def self.[](model_class)
@id_types ||= {}
@id_types ||= {
# WorkItem has a special class as we want to allow IssueID
# on WorkItemID while we transition into work items
::WorkItem => ::Types::WorkItemIdType
}
@id_types[model_class] ||= Class.new(self) do
model_name = model_class.name

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
module Types
# rubocop:disable Graphql/AuthorizeTypes
# TODO: This type should be removed when Work Items become generally available.
# This mechanism is introduced temporarily to make the client implementation easier during this transition.
class WorkItemIdType < GlobalIDType
graphql_name 'WorkItemID'
description <<~DESC
A `WorkItemID` is a global ID. It is encoded as a string.
An example `WorkItemID` is: `"gid://gitlab/WorkItem/1"`.
While we transition from Issues into Work Items this type will temporarily support
`IssueID` like: `"gid://gitlab/Issue/1"`. This behavior will be removed without notice in the future.
DESC
class << self
def coerce_result(gid, ctx)
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: 'WorkItem')
raise GraphQL::CoercionError, "Expected a WorkItem ID, got #{global_id}" unless suitable?(global_id)
# Always return a WorkItemID even if an Issue is returned by a resolver
work_item_gid(global_id).to_s
end
def coerce_input(string, ctx)
gid = super
# Always return a WorkItemID even if an Issue Global ID is provided as input
return work_item_gid(gid) if suitable?(gid)
raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of WorkItem"
end
def suitable?(gid)
return false if gid&.model_name&.safe_constantize.blank?
[::WorkItem, ::Issue].any? { |model_class| gid.model_class == model_class }
end
private
def work_item_gid(gid)
GlobalID.new(::Gitlab::GlobalId.build(model_name: 'WorkItem', id: gid.model_id))
end
end
end
# rubocop:enable Graphql/AuthorizeTypes
end

View File

@ -0,0 +1,5 @@
- @content_class = "limit-container-width"
- page_title s_('Secure Files')
#js-ci-secure-files{ data: { project_id: @project.id } }

View File

@ -0,0 +1,8 @@
---
name: import_relation_object_persistence
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79963
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/354497
milestone: '14.9'
type: development
group: group::import
default_enabled: false

View File

@ -96,6 +96,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :ci do
resource :lint, only: [:show, :create]
resource :pipeline_editor, only: [:show], controller: :pipeline_editor, path: 'editor'
resource :secure_files, only: [:show], controller: :secure_files, path: 'secure_files'
resources :daily_build_group_report_results, only: [:index], constraints: { format: /(csv|json)/ }
namespace :prometheus_metrics do
resources :histograms, only: [:create], constraints: { format: 'json' }

View File

@ -19145,6 +19145,9 @@ A `WorkItemID` is a global ID. It is encoded as a string.
An example `WorkItemID` is: `"gid://gitlab/WorkItem/1"`.
While we transition from Issues into Work Items this type will temporarily support
`IssueID` like: `"gid://gitlab/Issue/1"`. This behavior will be removed without notice in the future.
### `WorkItemsTypeID`
A `WorkItemsTypeID` is a global ID. It is encoded as a string.

View File

@ -9,6 +9,7 @@ module API
expose :permissions
expose :checksum
expose :checksum_algorithm
expose :created_at
end
end
end

View File

@ -0,0 +1,109 @@
# frozen_string_literal: true
# RelationObjectSaver allows for an alternative approach to persisting
# objects during Project/Group Import which persists object's
# nested collection subrelations separately, in batches.
#
# Instead of the regular `relation_object.save!` that opens one db
# transaction for the object itself and all of its subrelations we
# separate collection subrelations from the object and save them
# in batches in smaller more frequent db transactions.
module Gitlab
module ImportExport
module Base
class RelationObjectSaver
include Gitlab::Utils::StrongMemoize
BATCH_SIZE = 100
MIN_RECORDS_SIZE = 5
# @param relation_object [Object] Object of a project/group, e.g. an issue
# @param relation_key [String] Name of the object association to group/project, e.g. :issues
# @param relation_definition [Hash] Object subrelations as defined in import_export.yml
# @param importable [Project|Group] Project or group where relation object is getting saved to
#
# @example
# Gitlab::ImportExport::Base::RelationObjectSaver.new(
# relation_key: 'merge_requests',
# relation_object: #<MergeRequest id: root/mrs!1, notes: [#<Note id: nil, note: 'test', ...>, #<Note id: nil, noteL 'another note'>]>,
# relation_definition: {"metrics"=>{}, "award_emoji"=>{}, "notes"=>{"author"=>{}, ... }}
# importable: @importable
# ).execute
def initialize(relation_object:, relation_key:, relation_definition:, importable:)
@relation_object = relation_object
@relation_key = relation_key
@relation_definition = relation_definition
@importable = importable
@invalid_subrelations = []
end
def execute
move_subrelations
relation_object.save!
save_subrelations
ensure
log_invalid_subrelations
end
private
attr_reader :relation_object, :relation_key, :relation_definition,
:importable, :collection_subrelations, :invalid_subrelations
# rubocop:disable GitlabSecurity/PublicSend
def save_subrelations
collection_subrelations.each_pair do |relation_name, records|
records.each_slice(BATCH_SIZE) do |batch|
valid_records, invalid_records = batch.partition { |record| record.valid? }
invalid_subrelations << invalid_records
relation_object.public_send(relation_name) << valid_records
end
end
end
def move_subrelations
strong_memoize(:collection_subrelations) do
relation_definition.each_key.each_with_object({}) do |definition, collection_subrelations|
subrelation = relation_object.public_send(definition)
association = relation_object.class.reflect_on_association(definition)
if association&.collection? && subrelation.size > MIN_RECORDS_SIZE
collection_subrelations[definition] = subrelation.records
subrelation.clear
end
end
end
end
# rubocop:enable GitlabSecurity/PublicSend
def log_invalid_subrelations
invalid_subrelations.flatten.each do |record|
Gitlab::Import::Logger.info(
message: '[Project/Group Import] Invalid subrelation',
importable_column_name => importable.id,
relation_key: relation_key,
error_messages: record.errors.full_messages.to_sentence
)
ImportFailure.create(
source: 'RelationObjectSaver#save!',
relation_key: relation_key,
exception_class: 'RecordInvalid',
exception_message: record.errors.full_messages.to_sentence,
correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id,
importable_column_name => importable.id
)
end
end
def importable_column_name
@column_name ||= importable.class.reflect_on_association(:import_failures).foreign_key.to_sym
end
end
end
end
end

View File

@ -79,10 +79,7 @@ module Gitlab
relation_object.assign_attributes(importable_class_sym => @importable)
import_failure_service.with_retry(action: 'relation_object.save!', relation_key: relation_key, relation_index: relation_index) do
relation_object.save!
log_relation_creation(@importable, relation_key, relation_object)
end
save_relation_object(relation_object, relation_key, relation_definition, relation_index)
rescue StandardError => e
import_failure_service.log_import_failure(
source: 'process_relation_item!',
@ -91,6 +88,23 @@ module Gitlab
exception: e)
end
def save_relation_object(relation_object, relation_key, relation_definition, relation_index)
if Feature.enabled?(:import_relation_object_persistence, default_enabled: :yaml) && relation_object.new_record?
Gitlab::ImportExport::Base::RelationObjectSaver.new(
relation_object: relation_object,
relation_key: relation_key,
relation_definition: relation_definition,
importable: @importable
).execute
else
import_failure_service.with_retry(action: 'relation_object.save!', relation_key: relation_key, relation_index: relation_index) do
relation_object.save!
end
end
log_relation_creation(@importable, relation_key, relation_object)
end
def import_failure_service
@import_failure_service ||= ImportFailureService.new(@importable)
end

View File

@ -32383,6 +32383,9 @@ msgstr ""
msgid "Secret token"
msgstr ""
msgid "Secure Files"
msgstr ""
msgid "Secure token that identifies an external storage request."
msgstr ""
@ -39496,6 +39499,9 @@ msgstr ""
msgid "UploadLink|click to upload"
msgstr ""
msgid "Uploaded"
msgstr ""
msgid "Uploading changes to terminal"
msgstr ""
@ -39790,6 +39796,9 @@ msgstr ""
msgid "Use GitLab Runner in AWS"
msgstr ""
msgid "Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates."
msgstr ""
msgid "Use a one-time password authenticator on your mobile device or computer to enable two-factor authentication (2FA)."
msgstr ""

View File

@ -4,7 +4,7 @@ module QA
RSpec.describe 'Plan', :reliable do
let!(:user) do
Resource::User.fabricate_via_api! do |user|
user.name = "eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;"
user.name = "QA User <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;"
user.password = "test1234"
user.api_client = Runtime::API::Client.as_admin
end

View File

@ -53,6 +53,11 @@ module QA
# When we reach the last page, the x-next-page header is a blank string
@page_no = response.headers[:x_next_page].to_s
if @page_no.to_i > 1000
puts "Finishing early to avoid timing out the CI job"
exit
end
JSON.parse(response.body).select do |user|
user['username'].start_with?('qa-user-', 'test-user-') \
&& (user['name'] == 'QA Tests' || user['name'].start_with?('QA User')) \

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Ci::SecureFilesController do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
subject(:show_request) { get :show, params: { namespace_id: project.namespace, project_id: project } }
describe 'GET #show' do
context 'with enough privileges' do
before do
sign_in(user)
project.add_developer(user)
show_request
end
it { expect(response).to have_gitlab_http_status(:ok) }
it 'renders show page' do
expect(response).to render_template :show
end
end
context 'without enough privileges' do
before do
sign_in(user)
project.add_reporter(user)
show_request
end
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'an unauthenticated user' do
before do
show_request
end
it 'redirects to sign in' do
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to('/users/sign_in')
end
end
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Secure Files', :js do
let(:project) { create(:project) }
let(:user) { create(:user) }
before do
project.add_maintainer(user)
sign_in(user)
visit project_ci_secure_files_path(project)
end
it 'user sees the Secure Files list component' do
expect(page).to have_content('There are no records to show')
end
end

View File

@ -1619,6 +1619,28 @@ describe('Api', () => {
});
});
describe('projectSecureFiles', () => {
it('fetches secure files for a project', async () => {
const projectId = 1;
const secureFiles = [
{
id: projectId,
title: 'File Name',
permissions: 'read_only',
checksum: '12345',
checksum_algorithm: 'sha256',
created_at: '2022-02-21T15:27:18',
},
];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`;
mock.onGet(expectedUrl).reply(httpStatus.OK, secureFiles);
const { data } = await Api.projectSecureFiles(projectId, {});
expect(data).toEqual(secureFiles);
});
});
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;

View File

@ -0,0 +1,139 @@
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { mount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import SecureFilesList from '~/ci_secure_files/components/secure_files_list.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import waitForPromises from 'helpers/wait_for_promises';
import { secureFiles } from '../mock_data';
const dummyApiVersion = 'v3000';
const dummyProjectId = 1;
const dummyUrlRoot = '/gitlab';
const dummyGon = {
api_version: dummyApiVersion,
relative_url_root: dummyUrlRoot,
};
let originalGon;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/secure_files`;
describe('SecureFilesList', () => {
let wrapper;
let mock;
beforeEach(() => {
originalGon = window.gon;
window.gon = { ...dummyGon };
});
afterEach(() => {
wrapper.destroy();
mock.restore();
window.gon = originalGon;
});
const createWrapper = (props = {}) => {
wrapper = mount(SecureFilesList, {
provide: { projectId: dummyProjectId },
...props,
});
};
const findRows = () => wrapper.findAll('tbody tr');
const findRowAt = (i) => findRows().at(i);
const findCell = (i, col) => findRowAt(i).findAll('td').at(col);
const findHeaderAt = (i) => wrapper.findAll('thead th').at(i);
const findPagination = () => wrapper.findAll('ul.pagination');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
describe('when secure files exist in a project', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
await waitForPromises();
});
it('displays a table with expected headers', () => {
const headers = ['Filename', 'Permissions', 'Uploaded'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with rows', () => {
expect(findRows()).toHaveLength(secureFiles.length);
const [secureFile] = secureFiles;
expect(findCell(0, 0).text()).toBe(secureFile.name);
expect(findCell(0, 1).text()).toBe(secureFile.permissions);
expect(findCell(0, 2).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at);
});
});
describe('when no secure files exist in a project', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, []);
createWrapper();
await waitForPromises();
});
it('displays a table with expected headers', () => {
const headers = ['Filename', 'Permissions', 'Uploaded'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with a no records message', () => {
expect(findCell(0, 0).text()).toBe('There are no records to show');
});
});
describe('pagination', () => {
it('displays the pagination component with there are more than 20 items', async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles, { 'x-total': 30 });
createWrapper();
await waitForPromises();
expect(findPagination().exists()).toBe(true);
});
it('does not display the pagination component with there are 20 items', async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles, { 'x-total': 20 });
createWrapper();
await waitForPromises();
expect(findPagination().exists()).toBe(false);
});
});
describe('loading state', () => {
it('displays the loading icon while waiting for the backend request', () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the loading icon after the backend request has completed', async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
});
});

View File

@ -0,0 +1,18 @@
export const secureFiles = [
{
id: 1,
name: 'myfile.jks',
checksum: '16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aac',
checksum_algorithm: 'sha256',
permissions: 'read_only',
created_at: '2022-02-22T22:22:22.222Z',
},
{
id: 2,
name: 'myotherfile.jks',
checksum: '16630b189ab34b2e3504f4758e1054d2e478deda510b2b08cc0ef38d12e80aa2',
checksum_algorithm: 'sha256',
permissions: 'execute',
created_at: '2022-02-22T22:22:22.222Z',
},
];

View File

@ -376,4 +376,10 @@ RSpec.describe Types::GlobalIDType do
expect(described_class.model_name_to_graphql_name('DesignManagement::Design')).to eq('DesignManagementDesignID')
end
end
describe '.[]' do
it 'returns a custom class for work items' do
expect(described_class[::WorkItem]).to eq(::Types::WorkItemIdType)
end
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::WorkItemIdType do
let_it_be(:project) { create(:project) }
let_it_be(:work_item) { create(:work_item, project: project) }
let_it_be(:issue) { create(:issue, project: project) }
let(:work_item_gid) { work_item.to_gid }
let(:issue_gid) { issue.to_gid }
let(:ctx) { {} }
describe '.coerce_input' do
it 'can coerce valid issue input' do
coerced = described_class.coerce_input(issue_gid.to_s, ctx)
expect(coerced).to eq(WorkItem.find(issue.id).to_gid)
end
it 'can coerce valid work item input' do
coerced = described_class.coerce_input(work_item_gid.to_s, ctx)
expect(coerced).to eq(work_item_gid)
end
it 'fails for other input types' do
project_gid = project.to_gid
expect { described_class.coerce_input(project_gid.to_s, ctx) }
.to raise_error(GraphQL::CoercionError, "#{project_gid.to_s.inspect} does not represent an instance of WorkItem")
end
end
describe '.coerce_result' do
it 'can coerce issue results and return a WorkItem global ID' do
expect(described_class.coerce_result(issue_gid, ctx)).to eq(WorkItem.find(issue.id).to_gid.to_s)
end
it 'can coerce work item results' do
expect(described_class.coerce_result(work_item_gid, ctx)).to eq(work_item_gid.to_s)
end
it 'fails for other input types' do
project_gid = project.to_gid
expect { described_class.coerce_result(project_gid, ctx) }
.to raise_error(GraphQL::CoercionError, "Expected a WorkItem ID, got #{project_gid}")
end
end
end

View File

@ -0,0 +1,132 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::ImportExport::Base::RelationObjectSaver do
let(:project) { create(:project) }
let(:relation_object) { build(:issue, project: project) }
let(:relation_definition) { {} }
let(:importable) { project }
let(:relation_key) { 'issues' }
subject(:saver) do
described_class.new(
relation_object: relation_object,
relation_key: relation_key,
relation_definition: relation_definition,
importable: importable
)
end
describe '#save' do
before do
expect(relation_object).to receive(:save!).and_call_original
end
it 'saves relation object' do
expect { saver.execute }.to change(project.issues, :count).by(1)
end
context 'when subrelation is present' do
let(:notes) { build_list(:note, 6, project: project, importing: true) }
let(:relation_object) { build(:issue, project: project, notes: notes) }
let(:relation_definition) { { 'notes' => {} } }
it 'saves relation object with subrelations' do
expect(relation_object.notes).to receive(:<<).and_call_original
saver.execute
issue = project.issues.last
expect(issue.notes.count).to eq(6)
end
end
context 'when subrelation is not a collection' do
let(:sentry_issue) { build(:sentry_issue, importing: true) }
let(:relation_object) { build(:issue, project: project, sentry_issue: sentry_issue) }
let(:relation_definition) { { 'sentry_issue' => {} } }
it 'saves subrelation as part of the relation object itself' do
expect(relation_object.notes).not_to receive(:<<)
saver.execute
issue = project.issues.last
expect(issue.sentry_issue.persisted?).to eq(true)
end
end
context 'when subrelation collection count is small' do
let(:notes) { build_list(:note, 2, project: project, importing: true) }
let(:relation_object) { build(:issue, project: project, notes: notes) }
let(:relation_definition) { { 'notes' => {} } }
it 'saves subrelation as part of the relation object itself' do
expect(relation_object.notes).not_to receive(:<<)
saver.execute
issue = project.issues.last
expect(issue.notes.count).to eq(2)
end
end
context 'when some subrelations are invalid' do
let(:notes) { build_list(:note, 5, project: project, importing: true) }
let(:invalid_note) { build(:note) }
let(:relation_object) { build(:issue, project: project, notes: notes + [invalid_note]) }
let(:relation_definition) { { 'notes' => {} } }
it 'saves valid subrelations and logs invalid subrelation' do
expect(relation_object.notes).to receive(:<<).and_call_original
expect(Gitlab::Import::Logger)
.to receive(:info)
.with(
message: '[Project/Group Import] Invalid subrelation',
project_id: project.id,
relation_key: 'issues',
error_messages: "Noteable can't be blank and Project does not match noteable project"
)
saver.execute
issue = project.issues.last
import_failure = project.import_failures.last
expect(issue.notes.count).to eq(5)
expect(import_failure.source).to eq('RelationObjectSaver#save!')
expect(import_failure.exception_message).to eq("Noteable can't be blank and Project does not match noteable project")
end
context 'when importable is group' do
let(:relation_key) { 'labels' }
let(:relation_definition) { { 'priorities' => {} } }
let(:importable) { create(:group) }
let(:valid_priorities) { build_list(:label_priority, 5, importing: true) }
let(:invalid_priority) { build(:label_priority, priority: -1) }
let(:relation_object) { build(:group_label, group: importable, title: 'test', priorities: valid_priorities + [invalid_priority]) }
it 'logs invalid subrelation for a group' do
expect(Gitlab::Import::Logger)
.to receive(:info)
.with(
message: '[Project/Group Import] Invalid subrelation',
group_id: importable.id,
relation_key: 'labels',
error_messages: 'Priority must be greater than or equal to 0'
)
saver.execute
label = importable.labels.last
import_failure = importable.import_failures.last
expect(label.priorities.count).to eq(5)
expect(import_failure.source).to eq('RelationObjectSaver#save!')
expect(import_failure.exception_message).to eq('Priority must be greater than or equal to 0')
end
end
end
end
end

View File

@ -5,116 +5,117 @@ require 'spec_helper'
RSpec.describe Gitlab::ImportExport::Group::TreeRestorer do
include ImportExport::CommonUtil
describe 'restore group tree' do
before_all do
# Using an admin for import, so we can check assignment of existing members
user = create(:admin, email: 'root@gitlabexample.com')
create(:user, email: 'adriene.mcclure@gitlabexample.com')
create(:user, email: 'gwendolyn_robel@gitlabexample.com')
shared_examples 'group restoration' do
describe 'restore group tree' do
before_all do
# Using an admin for import, so we can check assignment of existing members
user = create(:admin, email: 'root@gitlabexample.com')
create(:user, email: 'adriene.mcclure@gitlabexample.com')
create(:user, email: 'gwendolyn_robel@gitlabexample.com')
RSpec::Mocks.with_temporary_scope do
@group = create(:group, name: 'group', path: 'group')
@shared = Gitlab::ImportExport::Shared.new(@group)
RSpec::Mocks.with_temporary_scope do
@group = create(:group, name: 'group', path: 'group')
@shared = Gitlab::ImportExport::Shared.new(@group)
setup_import_export_config('group_exports/complex')
setup_import_export_config('group_exports/complex')
group_tree_restorer = described_class.new(user: user, shared: @shared, group: @group)
group_tree_restorer = described_class.new(user: user, shared: @shared, group: @group)
expect(group_tree_restorer.restore).to be_truthy
expect(group_tree_restorer.groups_mapping).not_to be_empty
expect(group_tree_restorer.restore).to be_truthy
expect(group_tree_restorer.groups_mapping).not_to be_empty
end
end
it 'has the group description' do
expect(Group.find_by_path('group').description).to eq('Group Description')
end
it 'has group labels' do
expect(@group.labels.count).to eq(10)
end
context 'issue boards' do
it 'has issue boards' do
expect(@group.boards.count).to eq(1)
end
it 'has board label lists' do
lists = @group.boards.find_by(name: 'first board').lists
expect(lists.count).to eq(3)
expect(lists.first.label.title).to eq('TSL')
expect(lists.second.label.title).to eq('Sosync')
end
end
it 'has badges' do
expect(@group.badges.count).to eq(1)
end
it 'has milestones' do
expect(@group.milestones.count).to eq(5)
end
it 'has group children' do
expect(@group.children.count).to eq(2)
end
it 'has group members' do
expect(@group.members.map(&:user).map(&:email)).to contain_exactly(
'root@gitlabexample.com',
'adriene.mcclure@gitlabexample.com',
'gwendolyn_robel@gitlabexample.com'
)
end
end
it 'has the group description' do
expect(Group.find_by_path('group').description).to eq('Group Description')
end
context 'child with no parent' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
it 'has group labels' do
expect(@group.labels.count).to eq(10)
end
context 'issue boards' do
it 'has issue boards' do
expect(@group.boards.count).to eq(1)
before do
setup_import_export_config('group_exports/child_with_no_parent')
end
it 'has board label lists' do
lists = @group.boards.find_by(name: 'first board').lists
it 'captures import failures when a child group does not have a valid parent_id' do
group_tree_restorer.restore
expect(lists.count).to eq(3)
expect(lists.first.label.title).to eq('TSL')
expect(lists.second.label.title).to eq('Sosync')
expect(group.import_failures.first.exception_message).to eq('Parent group not found')
end
end
it 'has badges' do
expect(@group.badges.count).to eq(1)
context 'when child group creation fails' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
before do
setup_import_export_config('group_exports/child_short_name')
end
it 'captures import failure' do
exception_message = 'Validation failed: Group URL is too short (minimum is 2 characters)'
group_tree_restorer.restore
expect(group.import_failures.first.exception_message).to eq(exception_message)
end
end
it 'has milestones' do
expect(@group.milestones.count).to eq(5)
end
context 'excluded attributes' do
let!(:source_user) { create(:user, id: 123) }
let!(:importer_user) { create(:user) }
let(:group) { create(:group, name: 'user-inputed-name', path: 'user-inputed-path') }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: importer_user, shared: shared, group: group) }
let(:exported_file) { File.join(shared.export_path, 'tree/groups/4352.json') }
let(:group_json) { Gitlab::Json.parse(IO.read(exported_file)) }
it 'has group children' do
expect(@group.children.count).to eq(2)
end
it 'has group members' do
expect(@group.members.map(&:user).map(&:email)).to contain_exactly(
'root@gitlabexample.com',
'adriene.mcclure@gitlabexample.com',
'gwendolyn_robel@gitlabexample.com'
)
end
end
context 'child with no parent' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
before do
setup_import_export_config('group_exports/child_with_no_parent')
end
it 'captures import failures when a child group does not have a valid parent_id' do
group_tree_restorer.restore
expect(group.import_failures.first.exception_message).to eq('Parent group not found')
end
end
context 'when child group creation fails' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
before do
setup_import_export_config('group_exports/child_short_name')
end
it 'captures import failure' do
exception_message = 'Validation failed: Group URL is too short (minimum is 2 characters)'
group_tree_restorer.restore
expect(group.import_failures.first.exception_message).to eq(exception_message)
end
end
context 'excluded attributes' do
let!(:source_user) { create(:user, id: 123) }
let!(:importer_user) { create(:user) }
let(:group) { create(:group, name: 'user-inputed-name', path: 'user-inputed-path') }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: importer_user, shared: shared, group: group) }
let(:exported_file) { File.join(shared.export_path, 'tree/groups/4352.json') }
let(:group_json) { Gitlab::Json.parse(IO.read(exported_file)) }
shared_examples 'excluded attributes' do
excluded_attributes = %w[
shared_examples 'excluded attributes' do
excluded_attributes = %w[
id
parent_id
owner_id
@ -125,80 +126,97 @@ RSpec.describe Gitlab::ImportExport::Group::TreeRestorer do
saml_discovery_token
]
before do
group.add_owner(importer_user)
before do
group.add_owner(importer_user)
setup_import_export_config('group_exports/complex')
setup_import_export_config('group_exports/complex')
expect(File.exist?(exported_file)).to be_truthy
expect(File.exist?(exported_file)).to be_truthy
group_tree_restorer.restore
group.reload
end
group_tree_restorer.restore
group.reload
end
it 'does not import root group name' do
expect(group.name).to eq('user-inputed-name')
end
it 'does not import root group name' do
expect(group.name).to eq('user-inputed-name')
end
it 'does not import root group path' do
expect(group.path).to eq('user-inputed-path')
end
it 'does not import root group path' do
expect(group.path).to eq('user-inputed-path')
end
excluded_attributes.each do |excluded_attribute|
it 'does not allow override of excluded attributes' do
unless group.public_send(excluded_attribute).nil?
expect(group_json[excluded_attribute]).not_to eq(group.public_send(excluded_attribute))
excluded_attributes.each do |excluded_attribute|
it 'does not allow override of excluded attributes' do
unless group.public_send(excluded_attribute).nil?
expect(group_json[excluded_attribute]).not_to eq(group.public_send(excluded_attribute))
end
end
end
end
include_examples 'excluded attributes'
end
include_examples 'excluded attributes'
end
context 'group.json file access check' do
let(:user) { create(:user) }
let!(:group) { create(:group, name: 'group2', path: 'group2') }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
context 'group.json file access check' do
let(:user) { create(:user) }
let!(:group) { create(:group, name: 'group2', path: 'group2') }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
it 'does not read a symlink' do
Dir.mktmpdir do |tmpdir|
FileUtils.mkdir_p(File.join(tmpdir, 'tree', 'groups'))
setup_symlink(tmpdir, 'tree/groups/_all.ndjson')
it 'does not read a symlink' do
Dir.mktmpdir do |tmpdir|
FileUtils.mkdir_p(File.join(tmpdir, 'tree', 'groups'))
setup_symlink(tmpdir, 'tree/groups/_all.ndjson')
allow(shared).to receive(:export_path).and_return(tmpdir)
allow(shared).to receive(:export_path).and_return(tmpdir)
expect(group_tree_restorer.restore).to eq(false)
expect(shared.errors).to include('Incorrect JSON format')
end
end
end
context 'group visibility levels' do
let(:user) { create(:user) }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
before do
setup_import_export_config(filepath)
group_tree_restorer.restore
end
shared_examples 'with visibility level' do |visibility_level, expected_visibilities|
context "when visibility level is #{visibility_level}" do
let(:group) { create(:group, visibility_level) }
let(:filepath) { "group_exports/visibility_levels/#{visibility_level}" }
it "imports all subgroups as #{visibility_level}" do
expect(group.children.map(&:visibility_level)).to match_array(expected_visibilities)
expect(group_tree_restorer.restore).to eq(false)
expect(shared.errors).to include('Incorrect JSON format')
end
end
end
include_examples 'with visibility level', :public, [20, 10, 0]
include_examples 'with visibility level', :private, [0, 0, 0]
include_examples 'with visibility level', :internal, [10, 10, 0]
context 'group visibility levels' do
let(:user) { create(:user) }
let(:shared) { Gitlab::ImportExport::Shared.new(group) }
let(:group_tree_restorer) { described_class.new(user: user, shared: shared, group: group) }
before do
setup_import_export_config(filepath)
group_tree_restorer.restore
end
shared_examples 'with visibility level' do |visibility_level, expected_visibilities|
context "when visibility level is #{visibility_level}" do
let(:group) { create(:group, visibility_level) }
let(:filepath) { "group_exports/visibility_levels/#{visibility_level}" }
it "imports all subgroups as #{visibility_level}" do
expect(group.children.map(&:visibility_level)).to match_array(expected_visibilities)
end
end
end
include_examples 'with visibility level', :public, [20, 10, 0]
include_examples 'with visibility level', :private, [0, 0, 0]
include_examples 'with visibility level', :internal, [10, 10, 0]
end
end
context 'when import_relation_object_persistence feature flag is enabled' do
before do
stub_feature_flags(import_relation_object_persistence: true)
end
include_examples 'group restoration'
end
context 'when import_relation_object_persistence feature flag is disabled' do
before do
stub_feature_flags(import_relation_object_persistence: false)
end
include_examples 'group restoration'
end
end

View File

@ -1058,13 +1058,35 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do
end
end
context 'enable ndjson import' do
it_behaves_like 'project tree restorer work properly', :legacy_reader, true
context 'when import_relation_object_persistence feature flag is enabled' do
before do
stub_feature_flags(import_relation_object_persistence: true)
end
it_behaves_like 'project tree restorer work properly', :ndjson_reader, true
context 'enable ndjson import' do
it_behaves_like 'project tree restorer work properly', :legacy_reader, true
it_behaves_like 'project tree restorer work properly', :ndjson_reader, true
end
context 'disable ndjson import' do
it_behaves_like 'project tree restorer work properly', :legacy_reader, false
end
end
context 'disable ndjson import' do
it_behaves_like 'project tree restorer work properly', :legacy_reader, false
context 'when import_relation_object_persistence feature flag is disabled' do
before do
stub_feature_flags(import_relation_object_persistence: false)
end
context 'enable ndjson import' do
it_behaves_like 'project tree restorer work properly', :legacy_reader, true
it_behaves_like 'project tree restorer work properly', :ndjson_reader, true
end
context 'disable ndjson import' do
it_behaves_like 'project tree restorer work properly', :legacy_reader, false
end
end
end

View File

@ -154,6 +154,7 @@ RSpec.describe API::Ci::SecureFiles do
Digest::SHA256.hexdigest(fixture_file('ci_secure_files/upload-keystore.jks'))
)
expect(json_response['id']).to eq(secure_file.id)
expect(Time.parse(json_response['created_at'])).to be_like_time(secure_file.created_at)
end
it 'creates a secure file with read_only permissions by default' do

View File

@ -12,9 +12,10 @@ RSpec.describe 'Query.work_item(id)' do
let(:current_user) { developer }
let(:work_item_data) { graphql_data['workItem'] }
let(:work_item_fields) { all_graphql_fields_for('WorkItem') }
let(:global_id) { work_item.to_gid.to_s }
let(:query) do
graphql_query_for('workItem', { 'id' => work_item.to_gid.to_s }, work_item_fields)
graphql_query_for('workItem', { 'id' => global_id }, work_item_fields)
end
context 'when the user can read the work item' do
@ -35,6 +36,14 @@ RSpec.describe 'Query.work_item(id)' do
'workItemType' => hash_including('id' => work_item.work_item_type.to_gid.to_s)
)
end
context 'when an Issue Global ID is provided' do
let(:global_id) { Issue.find(work_item.id).to_gid.to_s }
it 'allows an Issue GID as input' do
expect(work_item_data).to include('id' => work_item.to_gid.to_s)
end
end
end
context 'when the user can not read the work item' do

View File

@ -899,6 +899,12 @@ RSpec.describe 'project routing' do
end
end
describe Projects::Ci::SecureFilesController, 'routing' do
it 'to #show' do
expect(get('/gitlab/gitlabhq/-/ci/secure_files')).to route_to('projects/ci/secure_files#show', namespace_id: 'gitlab', project_id: 'gitlabhq')
end
end
context 'with a non-existent project' do
it 'routes to 404 with get request' do
expect(get: "/gitlab/not_exist").to route_to(