diff --git a/.rubocop_todo/gitlab/bounded_contexts.yml b/.rubocop_todo/gitlab/bounded_contexts.yml index 9b1f404a78598e933215d82fcc80f2ac77830f08..0e227a6fcbeeeef1ba826bc0952c9d1ce3569329 100644 --- a/.rubocop_todo/gitlab/bounded_contexts.yml +++ b/.rubocop_todo/gitlab/bounded_contexts.yml @@ -3081,9 +3081,6 @@ Gitlab/BoundedContexts: - 'ee/app/services/dashboard/projects/create_service.rb' - 'ee/app/services/dashboard/projects/list_service.rb' - 'ee/app/services/dependencies/create_export_service.rb' - - 'ee/app/services/dependencies/export_serializers/group_dependencies_service.rb' - - 'ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb' - - 'ee/app/services/dependencies/export_serializers/project_dependencies_service.rb' - 'ee/app/services/dependencies/export_serializers/sbom/pipeline_service.rb' - 'ee/app/services/dependencies/export_service.rb' - 'ee/app/services/deployments/approval_service.rb' diff --git a/ee/app/models/dependencies/dependency_list_export.rb b/ee/app/models/dependencies/dependency_list_export.rb index 980d2aacc66cb0c2d8def644c2bc44ab41759d50..049eeb52b6079c01ebef22eaf9f009e9e6a77190 100644 --- a/ee/app/models/dependencies/dependency_list_export.rb +++ b/ee/app/models/dependencies/dependency_list_export.rb @@ -27,7 +27,9 @@ class DependencyListExport < Gitlab::Database::SecApplicationRecord enum export_type: { dependency_list: 0, - sbom: 1 + sbom: 1, + json_array: 2, + csv: 3 } scope :expired, -> { where(expires_at: ..Time.zone.now) } diff --git a/ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb b/ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb deleted file mode 100644 index fa0ff7540f0de5d0aac3dc62a65bae5b72fc2d9a..0000000000000000000000000000000000000000 --- a/ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Dependencies - module ExportSerializers - class OrganizationDependenciesService - def initialize(export) - @export = export - end - - def filename - "#{export.organization.to_param}_dependencies_#{Time.current.utc.strftime('%FT%H%M')}.csv" - end - - def each - yield header - - iterator.each_batch do |batch| - build_list_for(batch).each do |occurrence| - yield to_csv([ - occurrence.component_name, - occurrence.version, - occurrence.package_manager, - occurrence.location[:blob_path] - ]) - end - end - end - - private - - attr_reader :export - - def header - to_csv(%w[Name Version Packager Location]) - end - - def iterator - Gitlab::Pagination::Keyset::Iterator.new(scope: ::Sbom::Occurrence.order_by_id) - end - - def build_list_for(batch) - batch - .with_source - .with_version - .with_project_namespace - end - - def to_csv(row) - CSV.generate_line(row, force_quotes: true) - end - end - end -end diff --git a/ee/app/services/dependencies/export_serializers/project_dependencies_service.rb b/ee/app/services/dependencies/export_serializers/project_dependencies_service.rb deleted file mode 100644 index a3bc0ce60e93a78a2393a40c665339b85c50b1b9..0000000000000000000000000000000000000000 --- a/ee/app/services/dependencies/export_serializers/project_dependencies_service.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Dependencies - module ExportSerializers - class ProjectDependenciesService - def self.execute(dependency_list_export) - new(dependency_list_export).execute - end - - def initialize(dependency_list_export) - @dependency_list_export = dependency_list_export - end - - def execute - DependencyListEntity.represent(dependencies_list, serializer_parameters) - end - - private - - attr_reader :dependency_list_export - - delegate :project, :author, to: :dependency_list_export, private: true - - def dependencies_list - ::Sbom::DependenciesFinder.new(project, params: default_filters).execute - .with_component - .with_version - .with_vulnerabilities - end - - def default_filters - { source_types: default_source_type_filters } - end - - def default_source_type_filters - ::Sbom::Source::DEFAULT_SOURCES.keys + [nil] - end - - def pipeline - @pipeline ||= project.latest_ingested_sbom_pipeline - end - - def job_artifacts - ::Ci::JobArtifact.of_report_type(:dependency_list) - end - - def serializer_parameters - { - request: EntityRequest.new({ project: project, user: author }), - pipeline: pipeline, - project: project, - include_vulnerabilities: true - } - end - end - end -end diff --git a/ee/app/services/dependencies/export_serializers/sbom/pipeline_service.rb b/ee/app/services/dependencies/export_serializers/sbom/pipeline_service.rb index 5d58bb7a7691db3f6bbe533ea91f7200a81c19c2..03e10bd011889c9c841aa0d818d84f33db638581 100644 --- a/ee/app/services/dependencies/export_serializers/sbom/pipeline_service.rb +++ b/ee/app/services/dependencies/export_serializers/sbom/pipeline_service.rb @@ -4,32 +4,30 @@ module Dependencies module ExportSerializers module Sbom class PipelineService - SchemaValidationError = Class.new(StandardError) + include ::Sbom::Exporters::WriteBlob - # Creates and execute the service. - # - # @param dependency_list_export [Dependencies::DependencyListExport] - # @return [Sbom::SbomEntity] with sbom information of a pipeline. - def self.execute(dependency_list_export) - new(dependency_list_export).execute - end + SchemaValidationError = Class.new(StandardError) - def initialize(dependency_list_export) + def initialize(dependency_list_export, _sbom_occurrences) @dependency_list_export = dependency_list_export @pipeline = dependency_list_export.pipeline @project = pipeline.project end - def execute + def generate(&block) + write_json_blob(sbom_data, &block) + end + + private + + def sbom_data entity = serializer_service.execute return entity if serializer_service.valid? raise SchemaValidationError, "Invalid CycloneDX report: #{serializer_service.errors.join(', ')}" end - private - def serializer_service @service ||= ::Sbom::ExportSerializers::JsonService.new(merged_report) end diff --git a/ee/app/services/dependencies/export_service.rb b/ee/app/services/dependencies/export_service.rb index 085ea616b362cfc2bbd66b66f84b315a010c0fa6..7574cde9784d454abce20812fe56667158d3aa73 100644 --- a/ee/app/services/dependencies/export_service.rb +++ b/ee/app/services/dependencies/export_service.rb @@ -2,16 +2,12 @@ module Dependencies class ExportService - SERIALIZER_SERVICES = { - dependency_list: { - Organizations::Organization => ExportSerializers::OrganizationDependenciesService, - Project => ExportSerializers::ProjectDependenciesService, - Group => ExportSerializers::GroupDependenciesService - }, - sbom: { - Ci::Pipeline => ExportSerializers::Sbom::PipelineService - } - }.with_indifferent_access.freeze + EXPORTERS = { + dependency_list: ::Sbom::Exporters::DependencyListService, + sbom: ::Dependencies::ExportSerializers::Sbom::PipelineService, + json_array: ::Sbom::Exporters::JsonArrayService, + csv: ::Sbom::Exporters::CsvService + }.freeze def self.execute(dependency_list_export) new(dependency_list_export).execute @@ -36,80 +32,64 @@ def execute def create_export dependency_list_export.start! - - if exportable.is_a?(Organizations::Organization) - Tempfile.open('dependencies') do |file| - serializer = serializer_service.new(dependency_list_export) - serializer.each { |item| file << item } - - dependency_list_export.file = file - dependency_list_export.file.filename = serializer.filename - end - else - create_export_file - end - + write_export_file dependency_list_export.finish! dependency_list_export.send_completion_email! - rescue StandardError, Dependencies::ExportSerializers::Sbom::PipelineService::SchemaValidationError + rescue StandardError dependency_list_export.reset_state! raise end - def create_export_file - Tempfile.open('json') do |file| - file.write(file_content) - - dependency_list_export.file = file - dependency_list_export.file.filename = filename - end + def write_export_file + exporter.generate { |file| dependency_list_export.file = file } + dependency_list_export.file.filename = filename end - def file_content - ::Gitlab::Json.dump(exported_object) + def exporter + EXPORTERS[dependency_list_export.export_type.to_sym].new(dependency_list_export, sbom_occurrences) end - def exported_object - serializer_service.execute(dependency_list_export) + def sbom_occurrences + case exportable + when ::Project + ::Sbom::DependenciesFinder.new(exportable, params: default_filters).execute + when ::Group + exportable.sbom_occurrences.order_by_id + when ::Organizations::Organization + ::Sbom::Occurrence.order_by_id + end end - def serializer_service - SERIALIZER_SERVICES.fetch(dependency_list_export.export_type).fetch(exportable.class) + def default_filters + { source_types: default_source_type_filters } end - def filename - if dependency_list_export.export_type == 'sbom' - sbom_filename - else - dependency_list_filename - end + def default_source_type_filters + ::Sbom::Source::DEFAULT_SOURCES.keys + [nil] end - def sbom_filename - # Assuming dependency_list_export.export_type is sbom - # as we don't support dependency_list export_type for pipeline yet. + def filename [ - 'gl-', - 'pipeline-', + exportable.class.name.demodulize.underscore, + '_', exportable.id, - '-merged-', + '_dependencies_', Time.current.utc.strftime('%FT%H%M'), - '-sbom.', - 'cdx', '.', - 'json' + file_extension ].join end - def dependency_list_filename - [ - exportable.full_path.parameterize, - '_dependencies_', - Time.current.utc.strftime('%FT%H%M'), - '.', + def file_extension + case dependency_list_export.export_type + when 'sbom' + 'cdx.json' + when 'dependency_list', 'json_array' 'json' - ].join + when 'csv' + 'csv' + end end end end diff --git a/ee/app/services/sbom/exporters/csv_service.rb b/ee/app/services/sbom/exporters/csv_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..d74e77c1dff276afe6b68a6a8a5b84b396d9e3e2 --- /dev/null +++ b/ee/app/services/sbom/exporters/csv_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Sbom + module Exporters + class CsvService + attr_reader :sbom_occurrences + + def initialize(_export, sbom_occurrences) + @sbom_occurrences = sbom_occurrences + end + + def generate(&block) + csv_builder.render(&block) + end + + def header + CSV.generate_line(mapping.keys) + end + + private + + def preloads + [ + :source, + :component_version, + { project: [namespace: :route] } + ] + end + + def csv_builder + @csv_builder ||= CsvBuilder.new(sbom_occurrences, mapping, preloads, + replace_newlines: true) + end + + def mapping + { + s_('DependencyListExport|Name') => 'component_name', + s_('DependencyListExport|Version') => 'version', + s_('DependencyListExport|Packager') => 'package_manager', + s_('DependencyListExport|Location') => ->(occurrence) { occurrence.location[:blob_path] } + } + end + end + end +end diff --git a/ee/app/services/sbom/exporters/dependency_list_service.rb b/ee/app/services/sbom/exporters/dependency_list_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..65023fba73569b6411427c86ecac462b5e614404 --- /dev/null +++ b/ee/app/services/sbom/exporters/dependency_list_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Sbom + module Exporters + class DependencyListService + include WriteBlob + + attr_reader :export, :relation + + def initialize(export, relation) + @export = export + @relation = relation + end + + def generate(&block) + write_json_blob(blob, &block) + end + + private + + delegate :project, :author, to: :export + + def blob + DependencyListEntity.represent(sbom_occurrences, serializer_parameters) + end + + def sbom_occurrences + # rubocop:disable CodeReuse/ActiveRecord -- Preloading logic is coupled with DependencyListEntity + relation.preload(*preloads) + # rubocop:enable CodeReuse/ActiveRecord + end + + def preloads + [ + :component, + :component_version, + :vulnerabilities + ] + end + + def serializer_parameters + { + request: EntityRequest.new({ project: project, user: author }), + pipeline: pipeline, + project: project, + include_vulnerabilities: true + } + end + + def pipeline + project.latest_ingested_sbom_pipeline + end + end + end +end diff --git a/ee/app/services/dependencies/export_serializers/group_dependencies_service.rb b/ee/app/services/sbom/exporters/json_array_service.rb similarity index 50% rename from ee/app/services/dependencies/export_serializers/group_dependencies_service.rb rename to ee/app/services/sbom/exporters/json_array_service.rb index 33c05dda7ba67df3c5c189c0132a245a80ab6b08..628c716cc18b3db8252e6847fcbbdb15954b9438 100644 --- a/ee/app/services/dependencies/export_serializers/group_dependencies_service.rb +++ b/ee/app/services/sbom/exporters/json_array_service.rb @@ -1,30 +1,30 @@ # frozen_string_literal: true -module Dependencies - module ExportSerializers - class GroupDependenciesService - def self.execute(dependency_list_export) - new(dependency_list_export).execute +module Sbom + module Exporters + class JsonArrayService + include WriteBlob + + def initialize(_export, sbom_occurrences) + @sbom_occurrences = sbom_occurrences end - def initialize(dependency_list_export) - @dependency_list_export = dependency_list_export + attr_reader :sbom_occurrences + + def generate(&block) + write_json_blob(data, &block) end - def execute + private + + def data [].tap do |list| - group_dependencies.in_batches do |batch| # rubocop: disable Cop/InBatches + iterator.each_batch do |batch| list.concat(build_list_for(batch)) end end end - private - - attr_reader :dependency_list_export - - delegate :group, to: :dependency_list_export, private: true - def build_list_for(batch) batch.with_source.with_version.map do |occurrence| { @@ -37,8 +37,8 @@ def build_list_for(batch) end end - def group_dependencies - group.sbom_occurrences.order_by_id + def iterator + Gitlab::Pagination::Keyset::Iterator.new(scope: sbom_occurrences) end end end diff --git a/ee/app/services/sbom/exporters/write_blob.rb b/ee/app/services/sbom/exporters/write_blob.rb new file mode 100644 index 0000000000000000000000000000000000000000..59704e014924a88965c3cc5e5dfd5c3fe20a88cd --- /dev/null +++ b/ee/app/services/sbom/exporters/write_blob.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Sbom + module Exporters + module WriteBlob + def write_blob(blob) + Tempfile.open('write_blob') do |file| + file.write(blob) + + if block_given? + yield file + else + file.rewind + file.read + end + end + end + + def write_json_blob(data, &block) + blob = ::Gitlab::Json.dump(data) + write_blob(blob, &block) + end + end + end +end diff --git a/ee/lib/api/dependency_list_exports.rb b/ee/lib/api/dependency_list_exports.rb index f9967b6f273afe5bbfe74e5a5fb88d2b5232aa2b..77ea2e5bf4abc59b6e3f621ae3e59197e95dbb2c 100644 --- a/ee/lib/api/dependency_list_exports.rb +++ b/ee/lib/api/dependency_list_exports.rb @@ -45,7 +45,7 @@ def present_created_export(result) post ':id/dependency_list_exports' do authorize! :read_dependency, user_group - result = ::Dependencies::CreateExportService.new(user_group, current_user).execute + result = ::Dependencies::CreateExportService.new(user_group, current_user, :json_array).execute present_created_export(result) end @@ -63,7 +63,7 @@ def present_created_export(result) authorize! :read_dependency, organization result = ::Dependencies::CreateExportService - .new(organization, current_user) + .new(organization, current_user, :csv) .execute present_created_export(result) diff --git a/ee/spec/requests/api/dependency_list_exports_spec.rb b/ee/spec/requests/api/dependency_list_exports_spec.rb index aa2ad726958ff38ce596a11db504cf439af2f1b2..b3414745e903d042e4d21a5e747c41f972da0298 100644 --- a/ee/spec/requests/api/dependency_list_exports_spec.rb +++ b/ee/spec/requests/api/dependency_list_exports_spec.rb @@ -8,7 +8,7 @@ let_it_be(:project) { create(:project) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let(:export_type) { nil } - let(:params) { export_type } + let(:params) { { export_type: export_type } } shared_examples_for 'creating dependency list export' do subject(:create_dependency_list_export) { post api(request_path, user), params: params } @@ -53,12 +53,6 @@ stub_licensed_features(dependency_scanning: true, security_dashboard: true) end - let(:args) do - args = [exportable, user] - args << params[:export_type] if Hash(params)[:export_type] - args - end - it 'creates and returns a dependency_list_export' do create_dependency_list_export @@ -67,6 +61,9 @@ expect(json_response).to have_key('has_finished') expect(json_response).to have_key('self') expect(json_response).to have_key('download') + + created_export = ::Dependencies::DependencyListExport.find(json_response['id']) + expect(created_export.export_type).to eq(export_type) end context 'when export creation fails' do @@ -108,6 +105,9 @@ expect(json_response).to have_key('has_finished') expect(json_response).to have_key('self') expect(json_response).to have_key('download') + + created_export = ::Dependencies::DependencyListExport.find(json_response['id']) + expect(created_export.export_type).to eq('csv') end context 'when the `explore_dependencies` feature flag is disabled' do @@ -129,6 +129,7 @@ let(:request_path) { "/projects/#{project.id}/dependency_list_exports" } let(:resource) { project } let(:exportable) { project } + let(:export_type) { 'dependency_list' } it_behaves_like 'creating dependency list export' end @@ -137,6 +138,7 @@ let(:request_path) { "/groups/#{group.id}/dependency_list_exports" } let(:resource) { group } let(:exportable) { group } + let(:export_type) { 'json_array' } it_behaves_like 'creating dependency list export' end @@ -145,7 +147,7 @@ let(:request_path) { "/pipelines/#{pipeline.id}/dependency_list_exports" } let(:resource) { project } let(:exportable) { pipeline } - let(:params) { { export_type: 'sbom' } } + let(:export_type) { 'sbom' } it_behaves_like 'creating dependency list export' end diff --git a/ee/spec/services/dependencies/export_serializers/organization_dependencies_service_spec.rb b/ee/spec/services/dependencies/export_serializers/organization_dependencies_service_spec.rb deleted file mode 100644 index fca5dc216a69e00a39f9ad380af5e8607710a819..0000000000000000000000000000000000000000 --- a/ee/spec/services/dependencies/export_serializers/organization_dependencies_service_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Dependencies::ExportSerializers::OrganizationDependenciesService, feature_category: :dependency_management do - let_it_be(:organization) { create(:organization) } - let_it_be(:user) { create(:user) } - let_it_be_with_reload(:project) { create(:project, organization: organization) } - let_it_be(:export) { create(:dependency_list_export, project: nil, organization: organization, author: user) } - - let(:service_class) { described_class.new(export) } - - describe '#each' do - subject(:dependencies) { service_class.enum_for(:each).to_a } - - let(:header) { CSV.generate_line(%w[Name Version Packager Location], force_quotes: true) } - - context 'when the organization does not have dependencies' do - it { is_expected.to match_array(header) } - end - - context 'when the organization has dependencies' do - let_it_be(:bundler) { create(:sbom_component, :bundler) } - let_it_be(:bundler_v1) { create(:sbom_component_version, component: bundler, version: "1.0.0") } - - let_it_be(:occurrence_1) do - create(:sbom_occurrence, :mit, project: project, component: bundler, component_version: bundler_v1) - end - - context 'when the user is an organization owner' do - let_it_be(:organization_user) { create(:organization_user, :owner, organization: organization, user: user) } - - it 'includes each occurrence', :aggregate_failures do - expect(dependencies.count).to eq(2) - expect(dependencies).to match_array([ - header, - CSV.generate_line([ - occurrence_1.component_name, - occurrence_1.version, - occurrence_1.package_manager, - occurrence_1.location[:blob_path] - ], force_quotes: true) - ]) - end - end - - context 'when the user is an admin', :enable_admin_mode do - before_all do - user.update!(admin: true) - end - - it 'includes each occurrence' do - expect(dependencies).to match_array([ - header, - CSV.generate_line([ - occurrence_1.component_name, - occurrence_1.version, - occurrence_1.package_manager, - occurrence_1.location[:blob_path] - ], force_quotes: true) - ]) - end - end - - it 'avoids N+1 queries' do - control = ActiveRecord::QueryRecorder.new do - service_class.enum_for(:each).to_a - end - - create_list(:project, 3, organization: organization).each do |project| - create(:sbom_occurrence, project: project, source: create(:sbom_source)) - end - - expect do - service_class.enum_for(:each).to_a - end.to issue_same_number_of_queries_as(control).or_fewer - end - end - end - - describe '#filename' do - let(:timestamp) { Time.current.utc.strftime('%FT%H%M') } - - subject { service_class.filename } - - it { is_expected.to eq("#{organization.to_param}_dependencies_#{timestamp}.csv") } - end -end diff --git a/ee/spec/services/dependencies/export_serializers/sbom/pipeline_service_spec.rb b/ee/spec/services/dependencies/export_serializers/sbom/pipeline_service_spec.rb index d93818209b95e0b6b7ad21774dc211fb2583417e..629edce96d2ace5a4a11aa7ded3b573b669de28d 100644 --- a/ee/spec/services/dependencies/export_serializers/sbom/pipeline_service_spec.rb +++ b/ee/spec/services/dependencies/export_serializers/sbom/pipeline_service_spec.rb @@ -5,26 +5,12 @@ RSpec.describe Dependencies::ExportSerializers::Sbom::PipelineService, feature_category: :dependency_management do let_it_be(:pipeline) { create(:ee_ci_pipeline, :with_cyclonedx_report) } - describe '.execute' do - let(:dependency_list_export) { instance_double(Dependencies::DependencyListExport, pipeline: pipeline) } - - subject(:execute) { described_class.execute(dependency_list_export) } - - it 'instantiates a service object and sends execute message to it' do - expect_next_instance_of(described_class, dependency_list_export) do |service_object| - expect(service_object).to receive(:execute) - end - - execute - end - end - - describe '#execute' do + describe '#generate' do let(:dependency_list_export) { create(:dependency_list_export, project: nil, exportable: pipeline) } - let(:service_class) { described_class.new(dependency_list_export) } + let(:service_class) { described_class.new(dependency_list_export, nil) } - subject(:components) { service_class.execute.as_json[:components] } + subject(:components) { Gitlab::Json.parse(service_class.generate)['components'] } before do stub_licensed_features(dependency_scanning: true) @@ -56,7 +42,7 @@ end it 'raises a SchemaValidationError' do - expect { service_class.execute }.to raise_error( + expect { service_class.generate }.to raise_error( described_class::SchemaValidationError ).with_message(/Invalid CycloneDX report: /) end diff --git a/ee/spec/services/dependencies/export_service_spec.rb b/ee/spec/services/dependencies/export_service_spec.rb index 72140f83b924ec96db519704bf7e047bd1d023f6..0f9d9ce389c727e46b68e3447d0042414e087749 100644 --- a/ee/spec/services/dependencies/export_service_spec.rb +++ b/ee/spec/services/dependencies/export_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Dependencies::ExportService, feature_category: :dependency_management do + include ::Sbom::Exporters::WriteBlob + describe '.execute' do let(:dependency_list_export) { instance_double(Dependencies::DependencyListExport) } @@ -21,20 +23,22 @@ let(:created_status) { 0 } let(:running_status) { 1 } let(:finished_status) { 2 } + let(:status) { created_status } let(:service_class) { described_class.new(dependency_list_export) } + let(:export_content) { dependency_list_export.reload.file.read } + + subject(:execute) { described_class.new(dependency_list_export).execute } before do allow(Time).to receive(:current).and_return(Time.new(2023, 11, 14, 0, 0, 0, '+00:00')) end - shared_examples_for 'export service' do |serializer_service| - subject(:export) { service_class.execute } - + shared_examples_for 'writes export using exporter' do |exporter_class| context 'when the export is not in `created` status' do let(:status) { running_status } it 'does not run the logic' do - expect { export }.not_to change { dependency_list_export.reload.file.file }.from(nil) + expect { execute }.not_to change { dependency_list_export.reload.file.file }.from(nil) end end @@ -47,11 +51,13 @@ context 'when the export fails' do before do - allow(serializer_service).to receive(:execute).and_raise('Foo') + allow_next_instance_of(exporter_class, dependency_list_export, anything) do |instance| + allow(instance).to receive(:generate).and_raise('Foo') + end end it 'propagates the error, resets the status of the export, and does not schedule deletion job' do - expect { export }.to raise_error('Foo') + expect { execute }.to raise_error('Foo') .and not_change { dependency_list_export.status } expect(dependency_list_export).not_to have_received(:schedule_export_deletion) @@ -60,20 +66,22 @@ context 'when the export succeeds' do before do - allow(serializer_service).to receive(:execute).with(dependency_list_export).and_return('Foo') + allow_next_instance_of(exporter_class, dependency_list_export, anything) do |instance| + allow(instance).to receive(:generate) { |&block| write_blob('"Foo"', &block) } + end end it 'marks the export as finished' do - expect { export }.to change { dependency_list_export.status }.from(created_status).to(finished_status) + expect { execute }.to change { dependency_list_export.status }.from(created_status).to(finished_status) end it 'attaches the file to export' do - expect { export }.to change { dependency_list_export.file.read }.from(nil).to('"Foo"') + expect { execute }.to change { dependency_list_export.file.read }.from(nil).to('"Foo"') expect(dependency_list_export.file.filename).to eq(expected_filename) end it 'schedules the export deletion' do - export + execute expect(dependency_list_export).to have_received(:schedule_export_deletion) end @@ -81,118 +89,131 @@ end end - context 'when export type is dependency_list' do + context 'when the exportable is an organization' do + let_it_be(:organization) { create(:organization) } + let_it_be(:project) { create(:project, organization: organization) } + let_it_be(:occurrences) { create_list(:sbom_occurrence, 2, project: project) } + let_it_be_with_reload(:dependency_list_export) do + create(:dependency_list_export, project: nil, exportable: organization, export_type: :csv) + end + let(:timestamp) { Time.current.utc.strftime('%FT%H%M') } - let(:export_type) { :dependency_list } + let(:expected_filename) { "organization_#{organization.id}_dependencies_#{timestamp}.csv" } - context 'when the exportable is an organization' do - subject(:execute) { described_class.new(export).execute } + before_all do + project.add_developer(dependency_list_export.author) + end - let_it_be(:organization) { create(:organization) } - let_it_be(:project) { create(:project, organization: organization) } - let_it_be(:occurrences) { create_list(:sbom_occurrence, 2, project: project) } - let_it_be_with_reload(:export) { create(:dependency_list_export, project: nil, exportable: organization) } - let(:expected_filename) { "#{organization.to_param}_dependencies_#{timestamp}.csv" } + it { expect(execute).to be_present } + it { expect { execute }.to change { dependency_list_export.file.filename }.to(expected_filename) } - before_all do - project.add_developer(export.author) - end + it 'includes a header in the export file' do + header = 'Name,Version,Packager,Location' + expect { execute }.to change { dependency_list_export.file.read }.to(include(header)) + end - it { expect(execute).to be_present } - it { expect { execute }.to change { export.file.filename }.to(expected_filename) } + it 'includes a row for each occurrence' do + execute - it 'includes a header in the export file' do - header = '"Name","Version","Packager","Location"' - expect { execute }.to change { export.file.read }.to(include(header)) + occurrences.map do |occurrence| + expect(export_content).to include(CSV.generate_line([ + occurrence.component_name, + occurrence.version, + occurrence.package_manager, + occurrence.send(:input_file_blob_path) + ])) end + end + end - it 'includes a row for each occurrence' do - execute - - content = export.file.read - occurrences.map do |occurrence| - expect(content).to include(CSV.generate_line([ - occurrence.component_name, - occurrence.version, - occurrence.package_manager, - occurrence.send(:input_file_blob_path) - ], force_quotes: true)) - end - end + context 'when the exportable is a project' do + let_it_be(:project) { create(:project) } + let_it_be(:container_scanning_occurrence) { create(:sbom_occurrence, :os_occurrence, project: project) } + let_it_be(:registry_occurrence) { create(:sbom_occurrence, :registry_occurrence, project: project) } + + let(:export_type) { :dependency_list } + let(:dependency_list_export) do + create(:dependency_list_export, project: nil, exportable: project, status: status, export_type: export_type) end - context 'when the exportable is a project' do - let_it_be(:project) { create(:project) } - - let(:expected_filename) do - [ - project.full_path.parameterize, - '_dependencies_', - Time.current.utc.strftime('%FT%H%M'), - '.', - 'json' - ].join - end + let(:expected_filename) do + [ + 'project_', + project.id, + '_dependencies_', + Time.current.utc.strftime('%FT%H%M'), + '.', + 'json' + ].join + end - it_behaves_like 'export service', Dependencies::ExportSerializers::ProjectDependenciesService do - let(:dependency_list_export) do - create(:dependency_list_export, project: nil, exportable: project, status: status, export_type: export_type) - end - end + it 'does not include registry occurrences' do + execute + + expect(export_content).to include(container_scanning_occurrence.name) + expect(export_content).not_to include(registry_occurrence.name) end - context 'when the exportable is a group' do - let_it_be(:group) { create(:group) } - - let(:expected_filename) do - [ - group.full_path.parameterize, - '_dependencies_', - Time.current.utc.strftime('%FT%H%M'), - '.', - 'json' - ].join - end + it_behaves_like 'writes export using exporter', ::Sbom::Exporters::DependencyListService + end - it_behaves_like 'export service', Dependencies::ExportSerializers::GroupDependenciesService do - let(:dependency_list_export) do - create(:dependency_list_export, project: nil, exportable: group, status: status, export_type: export_type) - end - end + context 'when the exportable is a group' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:archived_project) { create(:project, :archived, group: group) } + let_it_be(:occurrence) { create(:sbom_occurrence, project: project) } + let_it_be(:archived_occurrence) { create(:sbom_occurrence, project: archived_project) } + + let(:export_type) { :json_array } + + let(:expected_filename) do + [ + 'group_', + group.id, + '_dependencies_', + Time.current.utc.strftime('%FT%H%M'), + '.', + 'json' + ].join + end + + let(:dependency_list_export) do + create(:dependency_list_export, project: nil, exportable: group, status: status, export_type: export_type) end + + it 'does not include occurrences from archived projects' do + execute + + expect(export_content).to include(occurrence.name) + expect(export_content).not_to include(archived_occurrence.name) + end + + it_behaves_like 'writes export using exporter', ::Sbom::Exporters::JsonArrayService end - context 'when export type is sbom' do - let(:export_type) { :sbom } - - context 'when the exportable is a pipeline' do - let_it_be(:pipeline) { create(:ci_pipeline) } - - let(:expected_filename) do - [ - 'gl-', - 'pipeline-', - pipeline.id, - '-merged-', - Time.current.utc.strftime('%FT%H%M'), - '-sbom.', - 'cdx', - '.', - 'json' - ].join - end + context 'when the exportable is a pipeline' do + let_it_be(:pipeline) { create(:ci_pipeline) } + + let(:expected_filename) do + [ + 'pipeline_', + pipeline.id, + '_dependencies_', + Time.current.utc.strftime('%FT%H%M'), + '.cdx.json' + ].join + end - it_behaves_like 'export service', Dependencies::ExportSerializers::Sbom::PipelineService do - let(:dependency_list_export) do - create(:dependency_list_export, { - project: nil, - exportable: pipeline, - status: status, - export_type: export_type - }) - end - end + let(:dependency_list_export) do + create(:dependency_list_export, { + project: nil, + exportable: pipeline, + status: status, + export_type: :sbom + }) end + + it_behaves_like 'writes export using exporter', ::Dependencies::ExportSerializers::Sbom::PipelineService end end end diff --git a/ee/spec/services/sbom/exporters/csv_service_spec.rb b/ee/spec/services/sbom/exporters/csv_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..60dfba84c46833cfe932da5dcaa392821850beb7 --- /dev/null +++ b/ee/spec/services/sbom/exporters/csv_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sbom::Exporters::CsvService, feature_category: :dependency_management do + let_it_be(:project) { create(:project) } + let_it_be(:export) { build_stubbed(:dependency_list_export) } + let_it_be(:sbom_occurrences) { Sbom::Occurrence.all } + + let(:service_class) { described_class.new(export, sbom_occurrences) } + + describe '#header' do + subject { service_class.header } + + it { is_expected.to eq("Name,Version,Packager,Location\n") } + end + + context 'when block is not given' do + it 'renders csv to string' do + expect(service_class.generate).to be_a String + end + end + + context 'when block is given' do + it 'returns handle to Tempfile' do + expect(service_class.generate { |file| file }).to be_a Tempfile + end + end + + describe '#generate' do + subject(:csv) { CSV.parse(service_class.generate, headers: true) } + + let(:header) { %w[Name Version Packager Location] } + + context 'when the organization does not have dependencies' do + it { is_expected.to match_array([header]) } + end + + context 'when the organization has dependencies' do + let_it_be(:bundler) { create(:sbom_component, :bundler) } + let_it_be(:bundler_v1) { create(:sbom_component_version, component: bundler, version: "1.0.0") } + + let_it_be(:occurrence) do + create(:sbom_occurrence, :mit, project: project, component: bundler, component_version: bundler_v1) + end + + it 'returns correct content' do + expect(csv[0]['Name']).to eq(occurrence.name) + expect(csv[0]['Version']).to eq(occurrence.version) + expect(csv[0]['Packager']).to eq(occurrence.package_manager) + expect(csv[0]['Location']).to eq(occurrence.location[:blob_path]) + end + + it 'avoids N+1 queries' do + control = ActiveRecord::QueryRecorder.new do + service_class.generate + end + + create_list(:sbom_occurrence, 3, project: project, source: create(:sbom_source)) + + expect do + service_class.generate + end.to issue_same_number_of_queries_as(control).or_fewer + end + end + end +end diff --git a/ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb b/ee/spec/services/sbom/exporters/dependency_list_service_spec.rb similarity index 70% rename from ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb rename to ee/spec/services/sbom/exporters/dependency_list_service_spec.rb index 7924ba9fe86fa595f03ff98bed0394acd541055c..2853ff9cb73f8b229035f18f49ce31175c538980 100644 --- a/ee/spec/services/dependencies/export_serializers/project_dependencies_service_spec.rb +++ b/ee/spec/services/sbom/exporters/dependency_list_service_spec.rb @@ -2,29 +2,16 @@ require 'spec_helper' -RSpec.describe Dependencies::ExportSerializers::ProjectDependenciesService, feature_category: :dependency_management do - describe '.execute' do - let(:dependency_list_export) { instance_double(Dependencies::DependencyListExport) } - - subject(:execute) { described_class.execute(dependency_list_export) } - - it 'instantiates a service object and sends execute message to it' do - expect_next_instance_of(described_class, dependency_list_export) do |service_object| - expect(service_object).to receive(:execute) - end - - execute - end - end - - describe '#execute' do +RSpec.describe Sbom::Exporters::DependencyListService, feature_category: :dependency_management do + describe '#generate' do let_it_be(:author) { create(:user) } let_it_be(:project) { create(:project, :public, developers: [author]) } let_it_be(:dependency_list_export) { create(:dependency_list_export, project: project, author: author) } + let_it_be(:sbom_occurrences) { project.sbom_occurrences } - let(:service_class) { described_class.new(dependency_list_export) } + let(:service_class) { described_class.new(dependency_list_export, sbom_occurrences) } - subject(:dependencies) { service_class.execute.as_json[:dependencies] } + subject(:dependencies) { Gitlab::Json.parse(service_class.generate)['dependencies'] } before do stub_licensed_features(dependency_scanning: true, license_scanning: true, security_dashboard: true) @@ -36,9 +23,6 @@ context 'when project has dependencies' do let_it_be(:occurrences) { create_list(:sbom_occurrence, 2, :with_vulnerabilities, :mit, project: project) } - let_it_be(:unexpected_occurrences) do - create(:sbom_occurrence, :registry_occurrence, :with_vulnerabilities, project: project) - end def json_dependency(occurrence) vulnerabilities = occurrence.vulnerabilities.map do |vulnerability| @@ -71,16 +55,12 @@ def json_dependency(occurrence) it 'returns expected dependencies' do expected_dependencies = occurrences.map { |occurrence| json_dependency(occurrence) } - expect(dependencies.as_json).to match_array(expected_dependencies) - end - - it 'returns data only for DEFAULT_SOURCES' do - expect(dependencies.pluck(:name)).not_to include(unexpected_occurrences.component_name) + expect(dependencies).to match_array(expected_dependencies) end it 'does not have N+1 queries', :request_store do def render - entity = described_class.new(dependency_list_export).execute + entity = described_class.new(dependency_list_export, sbom_occurrences).generate Gitlab::Json.dump(entity) end diff --git a/ee/spec/services/dependencies/export_serializers/group_dependencies_service_spec.rb b/ee/spec/services/sbom/exporters/json_array_service_spec.rb similarity index 73% rename from ee/spec/services/dependencies/export_serializers/group_dependencies_service_spec.rb rename to ee/spec/services/sbom/exporters/json_array_service_spec.rb index 029fe5f89d447c6798f675db05e4c3196ee5efea..5d842176712d6ded11c81596437bcd6fee6f118e 100644 --- a/ee/spec/services/dependencies/export_serializers/group_dependencies_service_spec.rb +++ b/ee/spec/services/sbom/exporters/json_array_service_spec.rb @@ -2,28 +2,13 @@ require 'spec_helper' -RSpec.describe Dependencies::ExportSerializers::GroupDependenciesService, feature_category: :dependency_management do - describe '.execute' do - let(:dependency_list_export) { instance_double(Dependencies::DependencyListExport) } +RSpec.describe Sbom::Exporters::JsonArrayService, feature_category: :dependency_management do + describe '#generate' do + let_it_be(:group) { create(:group) } + let(:sbom_occurrences) { Sbom::Occurrence.for_namespace_and_descendants(group).order_by_id } + let(:service_class) { described_class.new(nil, sbom_occurrences) } - subject(:execute) { described_class.execute(dependency_list_export) } - - it 'instantiates a service object and sends execute message to it' do - expect_next_instance_of(described_class, dependency_list_export) do |service_object| - expect(service_object).to receive(:execute) - end - - execute - end - end - - describe '#execute' do - let_it_be(:group) { create(:group, :public) } - let_it_be(:dependency_list_export) { create(:dependency_list_export, group: group, project: nil) } - - let(:service_class) { described_class.new(dependency_list_export) } - - subject(:dependencies) { service_class.execute.as_json } + subject(:dependencies) { Gitlab::Json.parse(service_class.generate) } before do stub_licensed_features(dependency_scanning: true) @@ -35,8 +20,6 @@ context 'when the group has dependencies' do let_it_be(:project) { create(:project, :public, group: group) } - let_it_be(:archived_project) { create(:project, :archived, group: group) } - let_it_be(:archived_occurrence) { create(:sbom_occurrence, project: archived_project) } let_it_be(:bundler) { create(:sbom_component, :bundler) } let_it_be(:bundler_v1) { create(:sbom_component_version, component: bundler, version: "1.0.0") } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9ff9f0810a3af0ad706fe8206e03613642a2ab81..b558283377d53fff5d55befd7845a1f0598cc7aa 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19501,6 +19501,18 @@ msgstr "" msgid "Dependency list" msgstr "" +msgid "DependencyListExport|Location" +msgstr "" + +msgid "DependencyListExport|Name" +msgstr "" + +msgid "DependencyListExport|Packager" +msgstr "" + +msgid "DependencyListExport|Version" +msgstr "" + msgid "DependencyProxy|%{docLinkStart}See the documentation%{docLinkEnd} for other ways to store Docker images in Dependency Proxy cache." msgstr ""