diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb index 5a9975695876a379fff4c2ac8d164f9590cd12a7..c532dbafbdf8813c47b7b5920f8b315268bc6ac2 100644 --- a/app/models/bulk_imports/export.rb +++ b/app/models/bulk_imports/export.rb @@ -13,6 +13,7 @@ class Export < ApplicationRecord belongs_to :project, optional: true belongs_to :group, optional: true belongs_to :user, optional: true + belongs_to :offline_export, optional: true, class_name: 'Import::Offline::Export' has_one :upload, class_name: 'BulkImports::ExportUpload' has_many :batches, class_name: 'BulkImports::ExportBatch' @@ -83,5 +84,9 @@ def remove_existing_upload! def relation_has_user_contributions? config.relation_has_user_contributions?(relation) end + + def offline? + offline_export_id.present? + end end end diff --git a/app/models/import/offline/export.rb b/app/models/import/offline/export.rb new file mode 100644 index 0000000000000000000000000000000000000000..0eee8632b24e14ed4180dd49edf474e4ca22b207 --- /dev/null +++ b/app/models/import/offline/export.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Import + module Offline + class Export < ApplicationRecord + self.table_name = 'import_offline_exports' + + KNOWN_IMPORT_HOSTS = %w[github.com bitbucket.org gitea.com].freeze + + belongs_to :user + belongs_to :organization, class_name: 'Organizations::Organization' + + has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :offline_export + + validates :source_hostname, :status, presence: true + validate :validate_source_hostname + + state_machine :status, initial: :created do + state :created, value: 0 + state :started, value: 1 + state :finished, value: 2 + state :failed, value: -1 + + event :start do + transition created: :started + end + + event :finish do + transition started: :finished + end + + event :fail_op do + transition any => :failed + end + end + + def validate_source_hostname + uri = Gitlab::Utils.parse_url(source_hostname) + + if KNOWN_IMPORT_HOSTS.include?(uri&.host) + return errors.add(:source_hostname, :invalid, message: 'must not be the host of a known import source') + end + + return if uri && uri.scheme && uri.host && uri.path.blank? && uri.query.blank? + + errors.add(:source_hostname, :invalid, message: 'must contain scheme and host, and not path or query') + end + end + end +end diff --git a/app/services/import/offline/exports/create_service.rb b/app/services/import/offline/exports/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..7d6ff589d4696edd4e68d3f256d1218f666b6d49 --- /dev/null +++ b/app/services/import/offline/exports/create_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Import + module Offline + module Exports + class CreateService + include ::Gitlab::Utils::StrongMemoize + + # @param current_user [User] current user object + # @param source_hostname [String] source hostname or alias hostname + # @param portable_params [Array] list of portables to export. + # Each portable hash must have at least its type and full path. E.g.: + # { type: 'project', full_path: 'gitlab-org/gitlab' } + def initialize(current_user, source_hostname, portable_params) + @current_user = current_user + @source_hostname = source_hostname + @portable_params = portable_params + end + + def execute + return feature_flag_disabled_error unless Feature.enabled?(:offline_transfer_exports, current_user) + return invalid_params_error unless portable_params_valid? + return insufficient_permissions_error unless user_can_export_all_portables? + + offline_export = Import::Offline::Export.create!( + user: current_user, + organization_id: current_user.organization_id, + source_hostname: source_hostname + ) + + ServiceResponse.success(payload: offline_export) + rescue ActiveRecord::RecordInvalid => e + service_error(e.message) + end + + private + + attr_reader :current_user, :source_hostname, :portable_params, :invalid_paths + + def user_can_export_all_portables? + full_path_params = portable_full_paths + found_full_paths = groups.map(&:full_path) + projects.map(&:full_path) + + @invalid_paths = full_path_params - found_full_paths + + @invalid_paths += [groups, projects].flatten.filter_map do |portable| + portable.full_path unless user_can_admin_portable?(portable) + end + + @invalid_paths.blank? + end + + def portable_params_valid? + return false if portable_params.blank? + return false if portable_params.any? { |h| !h.is_a?(Hash) || h[:type].blank? || h[:full_path].blank? } + + true + end + + def user_can_admin_portable?(portable) + ability = "admin_#{portable.to_ability_name}" + + current_user.can?(ability, portable) + end + + def groups + Group.where_full_path_in(portable_full_paths) + end + strong_memoize_attr :groups + + def projects + Project.where_full_path_in(portable_full_paths) + end + strong_memoize_attr :projects + + def portable_full_paths + portable_params.map { |params| params[:full_path] }.uniq # rubocop:disable Rails/Pluck -- Not an ActiveRecord object + end + strong_memoize_attr :portable_full_paths + + def feature_flag_disabled_error + service_error('offline_transfer_exports feature flag must be enabled.') + end + + def invalid_params_error + service_error(s_('OfflineTransfer|Export failed. Entity types and full paths must be provided.')) + end + + def insufficient_permissions_error + service_error(format( + s_('OfflineTransfer|Export failed. You do not have permission to ' \ + 'export the following resources or they do not exist: %{paths}'), + paths: invalid_paths.join(', ') + )) + end + + def service_error(message) + ServiceResponse.error( + message: message, + reason: :unprocessable_entity + ) + end + end + end + end +end diff --git a/config/feature_flags/wip/offline_transfer_exports.yml b/config/feature_flags/wip/offline_transfer_exports.yml new file mode 100644 index 0000000000000000000000000000000000000000..a42ba68a4b3caafecb2e35fbfb9c774be97a8017 --- /dev/null +++ b/config/feature_flags/wip/offline_transfer_exports.yml @@ -0,0 +1,9 @@ +--- +name: offline_transfer_exports +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/538941 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/209344 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/577715 +milestone: '18.6' +group: group::import +type: wip +default_enabled: false diff --git a/db/docs/import_offline_exports.yml b/db/docs/import_offline_exports.yml new file mode 100644 index 0000000000000000000000000000000000000000..288b9ebcc79b765e3f6ba18ea6c03b30db4c8604 --- /dev/null +++ b/db/docs/import_offline_exports.yml @@ -0,0 +1,12 @@ +--- +table_name: import_offline_exports +classes: +- Import::Offline::Export +feature_categories: +- importers +description: Used to define which exported relations belong to a single export +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/209344 +milestone: '18.6' +gitlab_schema: gitlab_main_org +sharding_key: + organization_id: organizations diff --git a/db/migrate/20251010195318_create_import_offline_exports.rb b/db/migrate/20251010195318_create_import_offline_exports.rb new file mode 100644 index 0000000000000000000000000000000000000000..52997c1e68c71fc1fc5b9a3c150ef826d55acde1 --- /dev/null +++ b/db/migrate/20251010195318_create_import_offline_exports.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateImportOfflineExports < Gitlab::Database::Migration[2.3] + milestone '18.6' + + def change + create_table :import_offline_exports do |t| + t.bigint :user_id, index: true, null: false + t.bigint :organization_id, index: true, null: false + t.integer :status, null: false, limit: 2, default: 0 + t.text :source_hostname, null: false, limit: 255 + t.boolean :has_failures, default: false, null: false + + t.timestamps_with_timezone null: false + end + end +end diff --git a/db/migrate/20251014192903_add_user_foreign_key_to_import_offline_exports.rb b/db/migrate/20251014192903_add_user_foreign_key_to_import_offline_exports.rb new file mode 100644 index 0000000000000000000000000000000000000000..dec7a56fa4e9cb4ffd0a43f5ddf230fb325b8151 --- /dev/null +++ b/db/migrate/20251014192903_add_user_foreign_key_to_import_offline_exports.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddUserForeignKeyToImportOfflineExports < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.6' + + def up + add_concurrent_foreign_key :import_offline_exports, :users, column: :user_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :import_offline_exports, column: :user_id + end + end +end diff --git a/db/migrate/20251014193254_add_organization_foreign_key_to_import_offline_exports.rb b/db/migrate/20251014193254_add_organization_foreign_key_to_import_offline_exports.rb new file mode 100644 index 0000000000000000000000000000000000000000..295357dace5ced675fae7cd91cae4d107cf4d902 --- /dev/null +++ b/db/migrate/20251014193254_add_organization_foreign_key_to_import_offline_exports.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddOrganizationForeignKeyToImportOfflineExports < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.6' + + def up + add_concurrent_foreign_key :import_offline_exports, :organizations, column: :organization_id, on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key :import_offline_exports, column: :organization_id + end + end +end diff --git a/db/migrate/20251014202556_add_offline_export_foreign_key_to_bulk_import_exports.rb b/db/migrate/20251014202556_add_offline_export_foreign_key_to_bulk_import_exports.rb new file mode 100644 index 0000000000000000000000000000000000000000..9028bb3d635a8193486215e07ef200a3010926fa --- /dev/null +++ b/db/migrate/20251014202556_add_offline_export_foreign_key_to_bulk_import_exports.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddOfflineExportForeignKeyToBulkImportExports < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + milestone '18.6' + + TABLE_NAME = :bulk_import_exports + COLUMN_NAME = :offline_export_id + + def up + add_column TABLE_NAME, COLUMN_NAME, :bigint, if_not_exists: true + + add_concurrent_index TABLE_NAME, COLUMN_NAME, name: "index_#{TABLE_NAME}_on_#{COLUMN_NAME}" + add_concurrent_foreign_key TABLE_NAME, :import_offline_exports, column: COLUMN_NAME, on_delete: :cascade + end + + def down + remove_column TABLE_NAME, COLUMN_NAME, if_exists: true + end +end diff --git a/db/schema_migrations/20251010195318 b/db/schema_migrations/20251010195318 new file mode 100644 index 0000000000000000000000000000000000000000..f909e54c3adbb0e009fc825ca3501860f128afbb --- /dev/null +++ b/db/schema_migrations/20251010195318 @@ -0,0 +1 @@ +8839e0bbb498dd99342916381ea33c90e6833fd484eff67d7faa507d3281ba2e \ No newline at end of file diff --git a/db/schema_migrations/20251014192903 b/db/schema_migrations/20251014192903 new file mode 100644 index 0000000000000000000000000000000000000000..ecd2e7b6eed00d42bd21dda92a315fb99cdf7d84 --- /dev/null +++ b/db/schema_migrations/20251014192903 @@ -0,0 +1 @@ +76f45fbfaae548e904b00b9a5bb6a70571b2fc099db445a5727c78f9ec0568d7 \ No newline at end of file diff --git a/db/schema_migrations/20251014193254 b/db/schema_migrations/20251014193254 new file mode 100644 index 0000000000000000000000000000000000000000..e04df450ce1a25c4e0f5b1f73c52a76861faf7ee --- /dev/null +++ b/db/schema_migrations/20251014193254 @@ -0,0 +1 @@ +60170e63657be5ca7049f674cfb4cf457541070792682039c75c141b8ffa0747 \ No newline at end of file diff --git a/db/schema_migrations/20251014202556 b/db/schema_migrations/20251014202556 new file mode 100644 index 0000000000000000000000000000000000000000..3f2b2d6e9a7ddabff652ea33b56634c8d451908a --- /dev/null +++ b/db/schema_migrations/20251014202556 @@ -0,0 +1 @@ +20ac7e742c7e63fc8c40f30886dfba0e0cdce793077a91defb70515a585cfffd \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index ed81bde46a9ffa4f46f636860b05da8a5af31b2f..c6eb56cea4c0be0a4ea2ca35231321fd2d6439b0 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -13082,6 +13082,7 @@ CREATE TABLE bulk_import_exports ( batches_count integer DEFAULT 0 NOT NULL, total_objects_count integer DEFAULT 0 NOT NULL, user_id bigint, + offline_export_id bigint, CONSTRAINT check_24cb010672 CHECK ((char_length(relation) <= 255)), CONSTRAINT check_8f0f357334 CHECK ((char_length(error) <= 255)), CONSTRAINT check_9ee6d14d33 CHECK ((char_length(jid) <= 255)), @@ -17806,6 +17807,27 @@ CREATE SEQUENCE import_failures_id_seq ALTER SEQUENCE import_failures_id_seq OWNED BY import_failures.id; +CREATE TABLE import_offline_exports ( + id bigint NOT NULL, + user_id bigint NOT NULL, + organization_id bigint NOT NULL, + status smallint DEFAULT 0 NOT NULL, + source_hostname text NOT NULL, + has_failures boolean DEFAULT false NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_dcd47fbc18 CHECK ((char_length(source_hostname) <= 255)) +); + +CREATE SEQUENCE import_offline_exports_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE import_offline_exports_id_seq OWNED BY import_offline_exports.id; + CREATE TABLE import_placeholder_memberships ( id bigint NOT NULL, source_user_id bigint NOT NULL, @@ -30987,6 +31009,8 @@ ALTER TABLE ONLY import_export_uploads ALTER COLUMN id SET DEFAULT nextval('impo ALTER TABLE ONLY import_failures ALTER COLUMN id SET DEFAULT nextval('import_failures_id_seq'::regclass); +ALTER TABLE ONLY import_offline_exports ALTER COLUMN id SET DEFAULT nextval('import_offline_exports_id_seq'::regclass); + ALTER TABLE ONLY import_placeholder_memberships ALTER COLUMN id SET DEFAULT nextval('import_placeholder_memberships_id_seq'::regclass); ALTER TABLE ONLY import_placeholder_user_details ALTER COLUMN id SET DEFAULT nextval('import_placeholder_user_details_id_seq'::regclass); @@ -33952,6 +33976,9 @@ ALTER TABLE ONLY import_export_uploads ALTER TABLE ONLY import_failures ADD CONSTRAINT import_failures_pkey PRIMARY KEY (id); +ALTER TABLE ONLY import_offline_exports + ADD CONSTRAINT import_offline_exports_pkey PRIMARY KEY (id); + ALTER TABLE ONLY import_placeholder_memberships ADD CONSTRAINT import_placeholder_memberships_pkey PRIMARY KEY (id); @@ -39015,6 +39042,8 @@ CREATE INDEX index_bulk_import_export_uploads_on_project_id ON bulk_import_expor CREATE INDEX index_bulk_import_exports_on_group_id ON bulk_import_exports USING btree (group_id); +CREATE INDEX index_bulk_import_exports_on_offline_export_id ON bulk_import_exports USING btree (offline_export_id); + CREATE INDEX index_bulk_import_exports_on_project_id ON bulk_import_exports USING btree (project_id); CREATE INDEX index_bulk_import_exports_on_user_id ON bulk_import_exports USING btree (user_id); @@ -40199,6 +40228,10 @@ CREATE INDEX index_import_failures_on_project_id_not_null ON import_failures USI CREATE INDEX index_import_failures_on_user_id_not_null ON import_failures USING btree (user_id) WHERE (user_id IS NOT NULL); +CREATE INDEX index_import_offline_exports_on_organization_id ON import_offline_exports USING btree (organization_id); + +CREATE INDEX index_import_offline_exports_on_user_id ON import_offline_exports USING btree (user_id); + CREATE INDEX index_import_placeholder_memberships_on_group_id ON import_placeholder_memberships USING btree (group_id); CREATE INDEX index_import_placeholder_memberships_on_namespace_id ON import_placeholder_memberships USING btree (namespace_id); @@ -47722,6 +47755,9 @@ ALTER TABLE ONLY incident_management_timeline_events ALTER TABLE ONLY terraform_state_versions ADD CONSTRAINT fk_180cde327a FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY bulk_import_exports + ADD CONSTRAINT fk_18441f89c5 FOREIGN KEY (offline_export_id) REFERENCES import_offline_exports(id) ON DELETE CASCADE; + ALTER TABLE ONLY project_features ADD CONSTRAINT fk_18513d9b92 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; @@ -48022,6 +48058,9 @@ ALTER TABLE ONLY user_project_callouts ALTER TABLE ONLY projects_branch_rules_squash_options ADD CONSTRAINT fk_33b614a558 FOREIGN KEY (protected_branch_id) REFERENCES protected_branches(id) ON DELETE CASCADE; +ALTER TABLE ONLY import_offline_exports + ADD CONSTRAINT fk_34265d27dc FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY namespaces ADD CONSTRAINT fk_3448c97865 FOREIGN KEY (push_rule_id) REFERENCES push_rules(id) ON DELETE SET NULL; @@ -48685,6 +48724,9 @@ ALTER TABLE ONLY merge_requests_approval_rules ALTER TABLE ONLY organization_cluster_agent_mappings ADD CONSTRAINT fk_7b441007e5 FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE ONLY import_offline_exports + ADD CONSTRAINT fk_7b730f0df3 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY issue_customer_relations_contacts ADD CONSTRAINT fk_7b92f835bb FOREIGN KEY (contact_id) REFERENCES customer_relations_contacts(id) ON DELETE CASCADE; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e64cd38d06f5f1ecf6256834c5d83ccc4e44e4ca..cb287ab23e17f223a73924c76e029e7ffb6840ec 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -45309,6 +45309,12 @@ msgstr "" msgid "Offline nodes automatically deleted after" msgstr "" +msgid "OfflineTransfer|Export failed. Entity types and full paths must be provided." +msgstr "" + +msgid "OfflineTransfer|Export failed. You do not have permission to export the following resources or they do not exist: %{paths}" +msgstr "" + msgid "Oh no!" msgstr "" diff --git a/spec/factories/bulk_import/exports.rb b/spec/factories/bulk_import/exports.rb index abe73a9314def2889e64c0f6d7a803cb55185479..298772e28aadb33b2bd8f02b5de1a988310c5ee9 100644 --- a/spec/factories/bulk_import/exports.rb +++ b/spec/factories/bulk_import/exports.rb @@ -24,5 +24,9 @@ trait :batched do batched { true } end + + trait :offline do + offline_export + end end end diff --git a/spec/factories/import/offline/exports.rb b/spec/factories/import/offline/exports.rb new file mode 100644 index 0000000000000000000000000000000000000000..61b7b77cfc93b87b92a45f68cfdd810f8e1d3fd2 --- /dev/null +++ b/spec/factories/import/offline/exports.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :offline_export, class: 'Import::Offline::Export' do + user + organization + + source_hostname { 'https://offline-environment-gitlab.example.com' } + + trait :created do + status { 0 } + end + + trait :started do + status { 1 } + end + + trait :finished do + status { 2 } + end + + trait :failed do + status { -1 } + end + end +end diff --git a/spec/models/bulk_imports/export_spec.rb b/spec/models/bulk_imports/export_spec.rb index 0732305802b7fb17d9e9243c4bee6490d3c120dd..09bbf3a291e71e07917d170e10181f01d476b439 100644 --- a/spec/models/bulk_imports/export_spec.rb +++ b/spec/models/bulk_imports/export_spec.rb @@ -6,6 +6,7 @@ describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:offline_export).class_name('Import::Offline::Export') } it { is_expected.to have_one(:upload) } it { is_expected.to have_many(:batches) } end @@ -176,4 +177,18 @@ it { is_expected.to eq(false) } end end + + describe '#offline?' do + context 'when associated to an offline export' do + subject(:export) { create(:bulk_import_export, :offline) } + + it { is_expected.to be_offline } + end + + context 'when not associated to an offline export' do + subject(:export) { create(:bulk_import_export) } + + it { is_expected.not_to be_offline } + end + end end diff --git a/spec/models/import/offline/export_spec.rb b/spec/models/import/offline/export_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..07df573b5ef240a7b1c8a8fe394f5cd166cd4b02 --- /dev/null +++ b/spec/models/import/offline/export_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::Offline::Export, feature_category: :importers do + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:source_hostname) } + it { is_expected.to validate_presence_of(:status) } + + describe '#source_hostname' do + it { is_expected.to allow_value('http://example.com:8080').for(:source_hostname) } + it { is_expected.to allow_value('https://example.com:8080').for(:source_hostname) } + it { is_expected.to allow_value('http://example.com').for(:source_hostname) } + it { is_expected.to allow_value('https://example.com').for(:source_hostname) } + it { is_expected.not_to allow_value('http://').for(:source_hostname) } + it { is_expected.not_to allow_value('example.com').for(:source_hostname) } + it { is_expected.not_to allow_value('https://example.com/dir').for(:source_hostname) } + it { is_expected.not_to allow_value('https://example.com?param=1').for(:source_hostname) } + it { is_expected.not_to allow_value('https://example.com/dir?param=1').for(:source_hostname) } + it { is_expected.not_to allow_value('https://github.com').for(:source_hostname) } + it { is_expected.not_to allow_value('https://bitbucket.org').for(:source_hostname) } + it { is_expected.not_to allow_value('https://gitea.com').for(:source_hostname) } + end + end +end diff --git a/spec/services/import/offline/exports/create_service_spec.rb b/spec/services/import/offline/exports/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..eca06d12ab18c7874a358af0a472f5abf9fc1cd8 --- /dev/null +++ b/spec/services/import/offline/exports/create_service_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::Offline::Exports::CreateService, :aggregate_failures, feature_category: :importers do + describe '#execute' do + let_it_be(:current_user) { create(:user) } + let_it_be(:groups) { create_list(:group, 2, owners: current_user) } + let_it_be(:projects) { create_list(:project, 2, maintainers: current_user) } + + let(:source_hostname) { 'https://offline-environment-gitlab.example.com' } + let(:portable_params) do + [ + { type: 'group', full_path: groups[0].full_path }, + { type: 'group', full_path: groups[1].full_path }, + { type: 'project', full_path: projects[0].full_path }, + { type: 'project', full_path: projects[1].full_path } + ] + end + + subject(:service) { described_class.new(current_user, source_hostname, portable_params) } + + shared_examples 'successfully creates an offline export' do + it 'creates an offline export object' do + result = service.execute + + expect(result).to be_success + expect(result.payload).to be_a(Import::Offline::Export) + expect(result.payload.user).to eq(current_user) + expect(result.payload.source_hostname).to eq(source_hostname) + end + end + + it_behaves_like 'successfully creates an offline export' + + context 'when only groups are exported' do + let(:portable_params) { [{ type: 'group', full_path: groups[0].full_path }] } + + it_behaves_like 'successfully creates an offline export' + end + + context 'when only projects are exported' do + let(:portable_params) { [{ type: 'project', full_path: projects[0].full_path }] } + + it_behaves_like 'successfully creates an offline export' + end + + context 'when portables contain duplicate paths' do + let(:portable_params) do + [ + { type: 'group', full_path: groups[0].full_path }, + { type: 'group', full_path: groups[0].full_path }, + { type: 'group', full_path: groups[1].full_path }, + { type: 'project', full_path: projects[0].full_path }, + { type: 'project', full_path: projects[1].full_path }, + { type: 'project', full_path: projects[1].full_path } + ] + end + + it_behaves_like 'successfully creates an offline export' + end + + context 'when portables are invalid' do + let_it_be(:unauthorized_group) { create(:group) } + let_it_be(:unauthorized_project) { create(:project) } + let_it_be(:low_access_group) { create(:group, maintainers: current_user) } + let_it_be(:low_access_project) { create(:project, developers: current_user) } + + let(:invalid_portable_error) do + 'You do not have permission to export the following resources or they do not exist' + end + + let(:portable_params) do + [ + { type: 'group', full_path: groups[0].full_path }, + { type: 'group', full_path: unauthorized_group.full_path }, + { type: 'group', full_path: low_access_group.full_path }, + { type: 'group', full_path: 'nonexistent/group' }, + { type: 'project', full_path: projects[0].full_path }, + { type: 'project', full_path: unauthorized_project.full_path }, + { type: 'project', full_path: low_access_project.full_path }, + { type: 'project', full_path: 'nonexistent/project' } + ] + end + + it 'returns a service error without differentiating nonexistant and unauthorized portables' do + invalid_paths = [ + unauthorized_group.full_path, low_access_group.full_path, 'nonexistent/group', + unauthorized_project.full_path, low_access_project.full_path, 'nonexistent/project' + ] + + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + expect(result.message).to include(invalid_portable_error) + invalid_paths.each { |path| expect(result.message).to include(path) } + end + end + + context 'when a portable type is not provided' do + let(:portable_params) do + [ + { type: '', full_path: groups[0].full_path }, + { type: 'project', full_path: projects[0].full_path } + ] + end + + it 'returns a service error' do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + expect(result.message).to include('Entity types and full paths must be provided') + end + end + + context 'when a portable full path is not provided' do + let(:portable_params) do + [ + { type: 'group', full_path: '' }, + { type: 'project', full_path: projects[0].full_path } + ] + end + + it 'returns a service error' do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + expect(result.message).to include('Entity types and full paths must be provided') + end + end + + context 'when the offline export fails validations' do + let(:source_hostname) { 'invalid-hostname' } + + it 'returns a service error' do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + expect(result.message).to include('must contain scheme and host') + end + end + + context 'when offline_transfer_exports is disabled' do + before do + stub_feature_flags(offline_transfer_exports: false) + end + + it 'returns a service response error' do + result = service.execute + + expect(result).to be_a(ServiceResponse) + expect(result).to be_error + expect(result.message).to eq('offline_transfer_exports feature flag must be enabled.') + end + end + end +end