Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
7fcb54624b
commit
0f6fb8a8c9
|
@ -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.) -->
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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 = () =>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { initCiSecureFiles } from '~/ci_secure_files';
|
||||
|
||||
initCiSecureFiles();
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
- @content_class = "limit-container-width"
|
||||
|
||||
- page_title s_('Secure Files')
|
||||
|
||||
#js-ci-secure-files{ data: { project_id: @project.id } }
|
|
@ -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
|
|
@ -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' }
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -9,6 +9,7 @@ module API
|
|||
expose :permissions
|
||||
expose :checksum
|
||||
expose :checksum_algorithm
|
||||
expose :created_at
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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)<img src=x onerror=alert(1)>"
|
||||
user.name = "QA User <img src=x onerror=alert(2)<img src=x onerror=alert(1)>"
|
||||
user.password = "test1234"
|
||||
user.api_client = Runtime::API::Client.as_admin
|
||||
end
|
||||
|
|
|
@ -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')) \
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
},
|
||||
];
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue