diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index b6ae765d6a9c41793961b25a2d1f3ed88d9b99b7..7f2dfcbdcf082c0e97233f5ff11a781f2302dc1b 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -979,6 +979,10 @@ - 1 - - vulnerabilities_archival_archive - 1 +- - vulnerabilities_archival_export_export + - 1 +- - vulnerabilities_archival_export_purge + - 1 - - vulnerabilities_mark_dropped_as_resolved - 1 - - vulnerabilities_namespace_historical_statistics_process_transfer_events diff --git a/db/migrate/20250304134327_index_vulnerability_archived_records_on_archive_id_and_id.rb b/db/migrate/20250304134327_index_vulnerability_archived_records_on_archive_id_and_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ea2d6554b97b745d8f91cbcf68d19f0fc567ff6 --- /dev/null +++ b/db/migrate/20250304134327_index_vulnerability_archived_records_on_archive_id_and_id.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class IndexVulnerabilityArchivedRecordsOnArchiveIdAndId < Gitlab::Database::Migration[2.2] + INDEX_NAME = 'index_vulnerability_archived_records_on_archive_id_and_id' + + disable_ddl_transaction! + milestone '17.10' + + def up + add_concurrent_index :vulnerability_archived_records, %i[archive_id id], name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :vulnerability_archived_records, INDEX_NAME + end +end diff --git a/db/migrate/20250304134552_drop_index_from_vulnerability_archived_records_on_archive_id.rb b/db/migrate/20250304134552_drop_index_from_vulnerability_archived_records_on_archive_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..6ffc11095459adf1b154b9b43fe8403b92feb1c6 --- /dev/null +++ b/db/migrate/20250304134552_drop_index_from_vulnerability_archived_records_on_archive_id.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class DropIndexFromVulnerabilityArchivedRecordsOnArchiveId < Gitlab::Database::Migration[2.2] + INDEX_NAME = 'index_vulnerability_archived_records_on_archive_id' + + disable_ddl_transaction! + milestone '17.10' + + def up + remove_concurrent_index_by_name :vulnerability_archived_records, INDEX_NAME + end + + def down + add_concurrent_index :vulnerability_archived_records, :archive_id, name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20250304134327 b/db/schema_migrations/20250304134327 new file mode 100644 index 0000000000000000000000000000000000000000..3e042c682706654e30ab9320f6068a814768ae4c --- /dev/null +++ b/db/schema_migrations/20250304134327 @@ -0,0 +1 @@ +22c4e9ded5b0474d1854de01a1b363394df170e63fc05b0a1b8d968aaa891505 \ No newline at end of file diff --git a/db/schema_migrations/20250304134552 b/db/schema_migrations/20250304134552 new file mode 100644 index 0000000000000000000000000000000000000000..923bcd16534ad14a723765b1d5236d053d1b15fa --- /dev/null +++ b/db/schema_migrations/20250304134552 @@ -0,0 +1 @@ +6d2337f6c66588dfe3d7e3f6fa2b0cf73160a2a0256b28cefb1f58f54c88adfb \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 54d7f6789e87723b2ed512daef59b6b946fe6ba8..b7b1f4d5852eb5e2f806268adaffed4a298ce67d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -36196,7 +36196,7 @@ CREATE INDEX index_vulnerability_archive_exports_on_project_id ON ONLY vulnerabi CREATE INDEX index_vulnerability_archive_exports_on_status ON ONLY vulnerability_archive_exports USING btree (status); -CREATE INDEX index_vulnerability_archived_records_on_archive_id ON vulnerability_archived_records USING btree (archive_id); +CREATE INDEX index_vulnerability_archived_records_on_archive_id_and_id ON vulnerability_archived_records USING btree (archive_id, id); CREATE INDEX index_vulnerability_archived_records_on_project_id ON vulnerability_archived_records USING btree (project_id); diff --git a/ee/app/models/vulnerabilities/archive_export.rb b/ee/app/models/vulnerabilities/archive_export.rb index 748fc56e59f2e6cfec96c7ad56fa269ddb7ef993..144d6693402d6d958454a71848097976008c2f6d 100644 --- a/ee/app/models/vulnerabilities/archive_export.rb +++ b/ee/app/models/vulnerabilities/archive_export.rb @@ -52,6 +52,10 @@ class ArchiveExport < Gitlab::Database::SecApplicationRecord transition running: :created end + event :purge do + transition any => :purged + end + before_transition created: :running do |export| export.started_at = Time.current end @@ -85,6 +89,14 @@ def first_record_in(partition_number) end end + def completed? + finished? || failed? + end + + def archives + project.vulnerability_archives.where(date: date_range) + end + def uploads_sharding_key { project_id: project_id } end diff --git a/ee/app/models/vulnerabilities/archived_record.rb b/ee/app/models/vulnerabilities/archived_record.rb index d277bfbe08ab2146e30a5de578eed6b7d3dd9878..6039e3a26c09f2f787c4fcaa414fde2b9b15d8ed 100644 --- a/ee/app/models/vulnerabilities/archived_record.rb +++ b/ee/app/models/vulnerabilities/archived_record.rb @@ -3,6 +3,7 @@ module Vulnerabilities class ArchivedRecord < Gitlab::Database::SecApplicationRecord include BulkInsertSafe + include EachBatch self.table_name = 'vulnerability_archived_records' diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index b3f91977d6f1d8853e9c0b6d0accba33bd57d84f..ac2db92ce1cf1710e624a16ab2fab43b2f2db437 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -564,6 +564,7 @@ module ProjectPolicy rule { can?(:read_security_resource) }.policy do enable :read_project_security_dashboard enable :create_vulnerability_export + enable :create_vulnerability_archive_export enable :admin_vulnerability_issue_link enable :admin_vulnerability_merge_request_link enable :admin_vulnerability_external_issue_link @@ -926,6 +927,7 @@ module ProjectPolicy enable :read_vulnerability enable :read_security_resource enable :create_vulnerability_export + enable :create_vulnerability_archive_export end rule { custom_role_enables_admin_merge_request }.policy do diff --git a/ee/app/policies/vulnerabilities/archive_export_policy.rb b/ee/app/policies/vulnerabilities/archive_export_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..e80fbf1a9f93bd93d9888e7f3008b6b4e8020409 --- /dev/null +++ b/ee/app/policies/vulnerabilities/archive_export_policy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Vulnerabilities + class ArchiveExportPolicy < BasePolicy + delegate { @subject.project } + + condition(:is_author) { @user && @subject.author == @user } + condition(:exportable) { can?(:create_vulnerability_archive_export, @subject.project) } + + rule { exportable & is_author }.policy do + enable :read_vulnerability_archive_export + end + end +end diff --git a/ee/app/services/vulnerabilities/archival/archived_record_builder_service.rb b/ee/app/services/vulnerabilities/archival/archived_record_builder_service.rb index 7784165a611237c01e5e7c2f932ea3b85ea6bf65..fafdb0b1016e64f52767d42c26c2fe8fc380d12a 100644 --- a/ee/app/services/vulnerabilities/archival/archived_record_builder_service.rb +++ b/ee/app/services/vulnerabilities/archival/archived_record_builder_service.rb @@ -41,6 +41,7 @@ def archive_data description: finding.description, cve_value: vulnerability.cve_value, cwe_value: vulnerability.cwe_value, + other_identifiers: vulnerability.other_identifier_values, created_at: vulnerability.created_at.to_s, location: vulnerability.location, resolved_on_default_branch: vulnerability.resolved_on_default_branch, diff --git a/ee/app/services/vulnerabilities/archival/export/create_service.rb b/ee/app/services/vulnerabilities/archival/export/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..d916c66115d84282fc5a595a0aafc712b03aec62 --- /dev/null +++ b/ee/app/services/vulnerabilities/archival/export/create_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Vulnerabilities + module Archival + module Export + class CreateService + include Gitlab::Allowable + + def initialize(project, author, start_date, end_date, format:) + @project = project + @author = author + @start_date = start_date + @end_date = end_date + @format = format + end + + def execute + authorize_author! + + schedule_export if archive_export.persisted? + + archive_export + end + + private + + attr_reader :project, :author, :start_date, :end_date, :format + + def authorize_author! + return if can?(author, :create_vulnerability_archive_export, project) + + raise Gitlab::Access::AccessDeniedError + end + + def archive_export + @archive_export ||= Vulnerabilities::ArchiveExport.create( + project: project, + author: author, + date_range: date_range, + format: format) + end + + def schedule_export + Vulnerabilities::Archival::Export::ExportWorker.perform_async(archive_export.id) + end + + def date_range + start_date..end_date + end + end + end + end +end diff --git a/ee/app/services/vulnerabilities/archival/export/export_service.rb b/ee/app/services/vulnerabilities/archival/export/export_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..6a2981d7f32fc12be5bffb6f9a852c69def321cd --- /dev/null +++ b/ee/app/services/vulnerabilities/archival/export/export_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Vulnerabilities + module Archival + module Export + class ExportService + EXPORTERS = { + 'csv' => Vulnerabilities::Archival::Export::Exporters::CsvService + }.freeze + + def self.export(archive_export) + new(archive_export).export + end + + def initialize(archive_export) + @archive_export = archive_export + end + + def export + archive_export.start! + + generate_export_file + + archive_export.store_file_now! + archive_export.finish! + + schedule_purging_export + rescue StandardError + archive_export.reset_state! + + raise + end + + private + + attr_reader :archive_export + + delegate :project, :format, :date_range, to: :archive_export, private: true + + def generate_export_file + exporter.generate { |f| archive_export.file = f } + archive_export.file.filename = filename + end + + def exporter + @exporter ||= EXPORTERS[archive_export.format].new(iterator) + end + + def iterator + Iterator.new(archive_export.archives) + end + + def schedule_purging_export + Vulnerabilities::Archival::Export::PurgeWorker.perform_in(24.hours, archive_export.id) + end + + def filename + [ + project.full_path.parameterize, + '_vulnerabilities_archive_', + date_range, + Time.current.utc.strftime('_%FT%H%M'), + '.', + format + ].join + end + + class Iterator + include Enumerable + + def initialize(archives) + @archives = archives + end + + def each(&block) + return to_enum(:each) unless block + + archives.each { |archive| yield_archived_records_of(archive, &block) } + end + + def yield_archived_records_of(archive) + archive.archived_records.each_batch do |batch| + batch.each { |archived_record| yield archived_record.data } + end + end + + private + + attr_reader :archives + end + end + end + end +end diff --git a/ee/app/services/vulnerabilities/archival/export/exporters/csv_service.rb b/ee/app/services/vulnerabilities/archival/export/exporters/csv_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f5aa1d8662c48e63f5e59b7f85197d52191303d --- /dev/null +++ b/ee/app/services/vulnerabilities/archival/export/exporters/csv_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Vulnerabilities + module Archival + module Export + module Exporters + class CsvService + CSV_DELIMITER = '; ' + + def initialize(iterator) + @iterator = iterator + end + + def generate(&block) + csv_builder.render(&block) + end + + private + + attr_reader :iterator + + def csv_builder + @csv_builder ||= CsvBuilder.new(iterator, mapping, replace_newlines: true) + end + + def mapping + { + 'Tool' => 'report_type', + 'Scanner Name' => 'scanner', + 'Status' => 'state', + 'Vulnerability' => 'title', + 'Details' => 'description', + 'Severity' => 'severity', + 'CVE' => 'cve_value', + 'CWE' => 'cwe_value', + 'Other Identifiers' => method(:identifier_formatter), + 'Detected At' => 'created_at', + 'Location' => 'location', + 'Activity' => 'resolved_on_default_branch', + 'Comments' => 'notes_summary', + 'Full Path' => 'full_path', + 'CVSS Vectors' => method(:cvss_formatter), + 'Dismissal Reason' => method(:dismissal_formatter) + } + end + + def identifier_formatter(data) + data['other_identifiers'].to_csv(col_sep: CSV_DELIMITER, row_sep: '') + end + + def dismissal_formatter(data) + data['dismissal_reason']&.humanize + end + + def cvss_formatter(data) + data['cvss'].map { |cvss| "#{cvss['vendor']}=#{cvss['vector']}" } + .to_csv(col_sep: CSV_DELIMITER, row_sep: '') + end + end + end + end + end +end diff --git a/ee/app/services/vulnerabilities/archival/export/purge_service.rb b/ee/app/services/vulnerabilities/archival/export/purge_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..6c95f3407a1fad4eed3c44494ed4edf5a37ea71b --- /dev/null +++ b/ee/app/services/vulnerabilities/archival/export/purge_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Vulnerabilities + module Archival + module Export + class PurgeService + def self.purge(archive_export) + new(archive_export).purge + end + + def initialize(archive_export) + @archive_export = archive_export + end + + def purge + archive_export.remove_file! + archive_export.purge! + end + + private + + attr_reader :archive_export + end + end + end +end diff --git a/ee/app/validators/json_schemas/archived_record_data.json b/ee/app/validators/json_schemas/archived_record_data.json index df5c6615d80efde3fb1cabfa5c4e9e79be1fa17a..f3788fa0c21d5c216b48e4930b1bf9691efcf38d 100644 --- a/ee/app/validators/json_schemas/archived_record_data.json +++ b/ee/app/validators/json_schemas/archived_record_data.json @@ -18,7 +18,7 @@ "type": "string" }, "description": { - "type": "string" + "type": ["null", "string"] }, "severity": { "type": "string" @@ -29,6 +29,12 @@ "cwe_value": { "type": ["null", "string"] }, + "other_identifiers": { + "type": "array", + "items": { + "type": "string" + } + }, "created_at": { "type": "date-time" }, diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml index de3c78ccfb05bdf15d093196db20ecb8e069579f..36010b8dafc11ff3b17a16f4e750d39140ee6155 100644 --- a/ee/app/workers/all_queues.yml +++ b/ee/app/workers/all_queues.yml @@ -3483,6 +3483,26 @@ :idempotent: true :tags: [] :queue_namespace: +- :name: vulnerabilities_archival_export_export + :worker_name: Vulnerabilities::Archival::Export::ExportWorker + :feature_category: :vulnerability_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: +- :name: vulnerabilities_archival_export_purge + :worker_name: Vulnerabilities::Archival::Export::PurgeWorker + :feature_category: :vulnerability_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: - :name: vulnerabilities_mark_dropped_as_resolved :worker_name: Vulnerabilities::MarkDroppedAsResolvedWorker :feature_category: :static_application_security_testing diff --git a/ee/app/workers/vulnerabilities/archival/export/export_worker.rb b/ee/app/workers/vulnerabilities/archival/export/export_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..eefe7663b79851d7e576cec668b8e1b5f0812c1b --- /dev/null +++ b/ee/app/workers/vulnerabilities/archival/export/export_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Vulnerabilities + module Archival + module Export + class ExportWorker + include ApplicationWorker + + data_consistency :sticky + idempotent! + deduplicate :until_executing, including_scheduled: true + + feature_category :vulnerability_management + + sidekiq_retries_exhausted do |job| + archive_export_id = job['args'].first + + Vulnerabilities::Archival::Export::PurgeWorker.perform_in(24.hours, archive_export_id) + end + + def perform(archive_export_id) + archive_export = Vulnerabilities::ArchiveExport.find_by_id(archive_export_id) + + return unless archive_export + + Vulnerabilities::Archival::Export::ExportService.export(archive_export) + end + end + end + end +end diff --git a/ee/app/workers/vulnerabilities/archival/export/purge_worker.rb b/ee/app/workers/vulnerabilities/archival/export/purge_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..09c4b609b2c5e12e6b66b42fa0d7e634af81d452 --- /dev/null +++ b/ee/app/workers/vulnerabilities/archival/export/purge_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Vulnerabilities + module Archival + module Export + class PurgeWorker + include ApplicationWorker + + data_consistency :sticky + idempotent! + deduplicate :until_executing, including_scheduled: true + + feature_category :vulnerability_management + + def perform(archive_export_id) + archive_export = Vulnerabilities::ArchiveExport.find_by_id(archive_export_id) + + return unless archive_export + + Vulnerabilities::Archival::Export::PurgeService.purge(archive_export) + end + end + end + end +end diff --git a/ee/lib/api/entities/vulnerability_archive_export.rb b/ee/lib/api/entities/vulnerability_archive_export.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec1d4fd921731b40e248b7c0f6d8d10883dd2f20 --- /dev/null +++ b/ee/lib/api/entities/vulnerability_archive_export.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module API + module Entities + class VulnerabilityArchiveExport < Grape::Entity + include ::API::Helpers::RelatedResourcesHelpers + + expose :id + expose :created_at + expose :project_id + expose :format + expose :status + expose :started_at + expose :finished_at + + expose :_links do + expose :self do |export| + expose_url api_v4_security_vulnerability_archive_exports_path(id: export.id) + end + + expose :download do |export| + expose_url api_v4_security_vulnerability_archive_exports_download_path(id: export.id) + end + end + end + end +end diff --git a/ee/lib/api/vulnerability_archive_exports.rb b/ee/lib/api/vulnerability_archive_exports.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbedfcf1328c72fbd13969cf506ff6b962912af9 --- /dev/null +++ b/ee/lib/api/vulnerability_archive_exports.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module API + class VulnerabilityArchiveExports < ::API::Base + feature_category :vulnerability_management + urgency :low + + helpers do + def vulnerability_archive_export + @vulnerability_archive_export ||= ::Vulnerabilities::ArchiveExport.find(params[:id]) + end + end + + before do + authenticate! + end + + namespace :security do + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + params do + requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' + requires :start_date, type: Date + requires :end_date, type: Date + optional :export_format, + type: String, desc: 'The format of export to be generated', + default: ::Vulnerabilities::ArchiveExport.formats.each_key.first, + values: ::Vulnerabilities::ArchiveExport.formats.keys + end + desc 'Generate an export file for the archived vulnerabilities within the given date range' do + success ::API::Entities::VulnerabilityArchiveExport + end + + post ':id/vulnerability_archive_exports' do + authorize! :create_vulnerability_archive_export, user_project + + service_object = ::Vulnerabilities::Archival::Export::CreateService.new( + user_project, + current_user, + declared_params[:start_date], + declared_params[:end_date], + format: declared_params[:export_format]) + + new_vulnerability_archive_export = service_object.execute + + if new_vulnerability_archive_export.persisted? + status :created + + present new_vulnerability_archive_export, with: ::API::Entities::VulnerabilityArchiveExport + else + render_validation_error!(new_vulnerability_archive_export) + end + end + end + + desc 'Get the vulnerability archive export entity' do + success ::API::Entities::VulnerabilityArchiveExport + end + get 'vulnerability_archive_exports/:id' do + authorize! :read_vulnerability_archive_export, vulnerability_archive_export + + unless vulnerability_archive_export.completed? + ::Gitlab::PollingInterval.set_api_header(self, interval: 5_000) + status :accepted + end + + present vulnerability_archive_export, with: ::API::Entities::VulnerabilityArchiveExport + end + + desc 'Download a single vulnerability export' + get 'vulnerability_archive_exports/:id/download' do + authorize! :read_vulnerability_archive_export, vulnerability_archive_export + + if vulnerability_archive_export.finished? + present_carrierwave_file!(vulnerability_archive_export.file, content_disposition: :attachment) + else + not_found!('Archive Export') + end + end + end + end +end diff --git a/ee/lib/ee/api/api.rb b/ee/lib/ee/api/api.rb index 922f9903251c108c498ec1a59ac36683f960699a..1b0e6643fc11e3e24db6b590436234f2d4c9a309 100644 --- a/ee/lib/ee/api/api.rb +++ b/ee/lib/ee/api/api.rb @@ -54,6 +54,7 @@ module API mount ::API::Vulnerabilities mount ::API::VulnerabilityFindings mount ::API::VulnerabilityIssueLinks + mount ::API::VulnerabilityArchiveExports mount ::API::VulnerabilityExports mount ::API::MergeRequestApprovalRules mount ::API::ProjectAliases diff --git a/ee/spec/factories/vulnerabilities/archive_exports.rb b/ee/spec/factories/vulnerabilities/archive_exports.rb index 2139057aba580d0ae3332df52454db0575957946..23770508a9d9c01888c6437c1235c884c5cd423e 100644 --- a/ee/spec/factories/vulnerabilities/archive_exports.rb +++ b/ee/spec/factories/vulnerabilities/archive_exports.rb @@ -16,5 +16,11 @@ status { :running } started_at { 1.minute.ago } end + + trait :finished do + with_csv_file + + status { :finished } + end end end diff --git a/ee/spec/factories/vulnerabilities/archived_records.rb b/ee/spec/factories/vulnerabilities/archived_records.rb index 6429bff7fa41d6b36712d35a66cf085fd49186aa..7e75c1e19dc31f729a026a64816a95ee9cf81991 100644 --- a/ee/spec/factories/vulnerabilities/archived_records.rb +++ b/ee/spec/factories/vulnerabilities/archived_records.rb @@ -15,6 +15,7 @@ description: 'Test Description', cve_value: 'CVE-2018-1234', cwe_value: 'CWE-123', + other_identifiers: ['OWASP-A01:2021'], created_at: '2025-01-29 19:02:08 UTC', location: { class: 'com.gitlab.security_products.tests.App', diff --git a/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_archive_export.json b/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_archive_export.json new file mode 100644 index 0000000000000000000000000000000000000000..b0206cc86f804d13561a2291348280d7f0f9ba01 --- /dev/null +++ b/ee/spec/fixtures/api/schemas/public_api/v4/vulnerability_archive_export.json @@ -0,0 +1,53 @@ +{ + "type" : "object", + "required": [ + "id", + "created_at", + "project_id", + "format", + "status", + "started_at", + "finished_at", + "_links" + ], + "properties" : { + "id": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "project_id": { + "type": "integer" + }, + "format": { + "type": "string", + "enum": ["csv"] + }, + "status": { + "type": "string", + "enum": ["created", "running", "finished", "failed", "purged"] + }, + "started_at": { + "type": ["string", "null"] + }, + "finished_at": { + "type": ["string", "null"] + }, + "_links": { + "type": "object", + "required": ["self", "download"], + "properties": { + "self": { + "type": "string" + }, + "download": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additional_properties" : false +} diff --git a/ee/spec/models/vulnerabilities/archive_export_spec.rb b/ee/spec/models/vulnerabilities/archive_export_spec.rb index 69f3c14693e1cd86a62de8b858d1c843a1c8ec1a..a9f88c050f66fa07d93939f5c3558aac64b12bbb 100644 --- a/ee/spec/models/vulnerabilities/archive_export_spec.rb +++ b/ee/spec/models/vulnerabilities/archive_export_spec.rb @@ -73,6 +73,16 @@ expect { reset_state }.to change { export.reload.started_at }.to(nil) end end + + describe '#purge' do + let(:export) { create(:vulnerability_archive_export, :running) } + + subject(:purge) { export.purge! } + + it 'sets the status of the record as purged' do + expect { purge }.to change { export.reload.status }.to('purged') + end + end end describe 'partition helpers' do @@ -125,4 +135,38 @@ end end end + + describe '#completed?' do + using RSpec::Parameterized::TableSyntax + + where(:status, :completed?) do + :created | false + :running | false + :finished | true + :failed | true + :purged | false + end + + with_them do + let(:archive_export) { build(:vulnerability_archive_export, status: status) } + + subject { archive_export.completed? } + + it { is_expected.to eq(completed?) } + end + end + + describe '#archives', :freeze_time do + let_it_be(:project) { create(:project) } + let_it_be(:project_archive_1) { create(:vulnerability_archive, project: project, date: 3.months.ago) } + let_it_be(:project_archive_2) { create(:vulnerability_archive, project: project, date: 1.month.ago) } + let_it_be(:another_project_archive) { create(:vulnerability_archive, date: 1.month.ago) } + + let_it_be(:date_range) { project_archive_2.date..Time.zone.today } + let_it_be(:archive_export) { create(:vulnerability_archive_export, project: project, date_range: date_range) } + + subject { archive_export.archives } + + it { is_expected.to contain_exactly(project_archive_2) } + end end diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 80a45e95bef77b9f134949dcb45903d24da957de..6ca952799d05428a1eba48f07cb724eb8f756f6d 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -806,15 +806,15 @@ end describe 'vulnerability permissions' do - describe 'dismiss_vulnerability' do - context 'with developer' do - let(:current_user) { developer } + context 'with developer' do + let(:current_user) { developer } - include_context 'when security dashboard feature is not available' - it { is_expected.to be_disallowed(:admin_vulnerability) } - it { is_expected.to be_disallowed(:read_vulnerability) } - it { is_expected.to be_disallowed(:create_vulnerability_export) } - end + include_context 'when security dashboard feature is not available' + + it { is_expected.to be_disallowed(:admin_vulnerability) } + it { is_expected.to be_disallowed(:read_vulnerability) } + it { is_expected.to be_disallowed(:create_vulnerability_export) } + it { is_expected.to be_disallowed(:create_vulnerability_archive_export) } end end @@ -2912,6 +2912,7 @@ def create_member_role(member, abilities = member_role_abilities) [ :access_security_and_compliance, :create_vulnerability_export, + :create_vulnerability_archive_export, :read_security_resource, :read_vulnerability, :read_vulnerability_feedback, diff --git a/ee/spec/policies/vulnerabilities/archive_export_policy_spec.rb b/ee/spec/policies/vulnerabilities/archive_export_policy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..035da4d0a811be5bfca0b3b2f2b45df6f03e7772 --- /dev/null +++ b/ee/spec/policies/vulnerabilities/archive_export_policy_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::ArchiveExportPolicy, feature_category: :vulnerability_management do + let_it_be_with_refind(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let(:archive_export) { create(:vulnerability_archive_export, project: project, author: author) } + + subject { described_class.new(user, archive_export) } + + context 'when security dashboard is licensed' do + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when the user is the author of the archive export' do + let(:author) { user } + + context 'when user has access to vulnerabilities from the project' do + before_all do + project.add_developer(user) + end + + it { is_expected.to be_allowed(:read_vulnerability_archive_export) } + end + + context 'when user has no access to vulnerabilities from the project' do + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + end + + context 'when the user is not the author of the archive export' do + let(:author) { create(:user) } + + context 'when user has access to vulnerabilities from the project' do + before_all do + project.add_developer(user) + end + + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + + context 'when user has no access to vulnerabilities from the project' do + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + end + end + + context 'when security dashboard is not licensed' do + before do + stub_licensed_features(security_dashboard: false) + end + + context 'when the user is the author of the archive export' do + let(:author) { user } + + context 'when user has access to vulnerabilities from the project' do + before_all do + project.add_developer(user) + end + + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + + context 'when user has no access to vulnerabilities from the project' do + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + end + + context 'when the user is not the author of the archive export' do + let(:author) { create(:user) } + + context 'when user has access to vulnerabilities from the project' do + before_all do + project.add_developer(user) + end + + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + + context 'when user has no access to vulnerabilities from the project' do + it { is_expected.to be_disallowed(:read_vulnerability_archive_export) } + end + end + end +end diff --git a/ee/spec/requests/api/vulnerability_archive_exports_spec.rb b/ee/spec/requests/api/vulnerability_archive_exports_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba4bdf7c617c2d9c4f4e3b9eb7653841773a0818 --- /dev/null +++ b/ee/spec/requests/api/vulnerability_archive_exports_spec.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::VulnerabilityArchiveExports, feature_category: :vulnerability_management do + include AccessMatchersForRequest + + before do + stub_licensed_features(security_dashboard: true) + end + + let_it_be_with_refind(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + describe 'POST /security/projects/:id/vulnerability_archive_exports' do + let(:request_path) { "/security/projects/#{project.id}/vulnerability_archive_exports" } + let(:export_format) { :csv } + let(:request_params) do + { + export_format: export_format, + start_date: '01/01/2025', + end_date: '01/01/2025' + } + end + + subject(:create_archive_export) { post api(request_path, user), params: request_params } + + context 'when the request does not fulfill the requirements' do + let(:export_format) { 'exif' } + + it 'responds with bad_request', :aggregate_failures do + create_archive_export + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq('error' => 'export_format does not have a valid value') + end + end + + context 'when the request fulfills the requirements' do + let(:mock_service_object) do + instance_double(::Vulnerabilities::Archival::Export::CreateService, execute: archive_export) + end + + before_all do + project.add_developer(user) + end + + before do + allow(::Vulnerabilities::Archival::Export::CreateService).to receive(:new).and_return(mock_service_object) + end + + context 'when the export creation succeeds' do + let(:archive_export) { create(:vulnerability_archive_export) } + + it 'returns information about new archive export', :aggregate_failures do + create_archive_export + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/vulnerability_archive_export', dir: 'ee') + end + end + + context 'when the export creation fails' do + let(:errors) { instance_double(ActiveModel::Errors, any?: true, messages: ['foo']) } + let(:archive_export) { instance_double(Vulnerabilities::ArchiveExport, persisted?: false, errors: errors) } + + it 'returns the error message', :aggregate_failures do + create_archive_export + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq('message' => ['foo']) + end + end + end + + describe 'permissions', :enable_admin_mode do + it { expect { create_archive_export }.to be_allowed_for(:admin) } + it { expect { create_archive_export }.to be_allowed_for(:owner).of(project) } + it { expect { create_archive_export }.to be_allowed_for(:maintainer).of(project) } + it { expect { create_archive_export }.to be_allowed_for(:developer).of(project) } + it { expect { create_archive_export }.to be_allowed_for(:auditor) } + + it { expect { create_archive_export }.to be_denied_for(:reporter).of(project) } + it { expect { create_archive_export }.to be_denied_for(:guest).of(project) } + it { expect { create_archive_export }.to be_denied_for(:anonymous) } + end + + it_behaves_like 'forbids access to vulnerability API endpoint in case of disabled features' do + before_all do + project.add_developer(user) + end + end + end + + describe 'GET /security/vulnerability_archive_exports/:id' do + let_it_be(:archive_export) { create(:vulnerability_archive_export, :finished, project: project, author: user) } + + let(:request_path) { "/security/vulnerability_archive_exports/#{archive_export.id}" } + + subject(:get_archive_export) { get api(request_path, user) } + + context 'with an authorized user with proper permissions' do + before_all do + project.add_developer(user) + end + + context 'when export is finished' do + it 'returns information about archive export', :aggregate_failures do + get_archive_export + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/vulnerability_archive_export', dir: 'ee') + expect(json_response['id']).to eq archive_export.id + end + + it 'does not return Poll-Interval header' do + get_archive_export + + expect(response.headers['Poll-Interval']).to be_blank + end + end + + context 'when export is running' do + before do + archive_export.update_column(:status, 'running') + end + + it 'returns information about archive export', :aggregate_failures do + get_archive_export + + expect(response).to have_gitlab_http_status(:accepted) + expect(response).to match_response_schema('public_api/v4/vulnerability_archive_export', dir: 'ee') + expect(json_response['id']).to eq archive_export.id + end + + it 'returns Poll-Interval header with value set to 5 seconds' do + get_archive_export + + expect(response.headers['Poll-Interval']).to eq '5000' + end + end + end + + describe 'permissions', :enable_admin_mode do + context 'for export author' do + before_all do + project.add_developer(user) + end + + it { expect { get_archive_export }.to be_allowed_for(user) } + end + + context 'for other users' do + it { expect { get_archive_export }.to be_denied_for(:admin) } + it { expect { get_archive_export }.to be_denied_for(:owner).of(project) } + it { expect { get_archive_export }.to be_denied_for(:maintainer).of(project) } + it { expect { get_archive_export }.to be_denied_for(:developer).of(project) } + it { expect { get_archive_export }.to be_denied_for(:auditor) } + it { expect { get_archive_export }.to be_denied_for(:reporter).of(project) } + it { expect { get_archive_export }.to be_denied_for(:guest).of(project) } + it { expect { get_archive_export }.to be_denied_for(:anonymous) } + end + end + + it_behaves_like 'forbids access to vulnerability API endpoint in case of disabled features' do + before_all do + project.add_developer(user) + end + end + end + + describe 'GET /security/vulnerability_archive_exports/:id/download' do + let!(:archive_export) { create(:vulnerability_archive_export, :finished, project: project, author: user) } + let(:request_path) { "/security/vulnerability_archive_exports/#{archive_export.id}/download" } + + subject(:download_export) { get api(request_path, user) } + + context 'with an authorized user with proper permissions' do + before_all do + project.add_developer(user) + end + + context 'when export is running' do + before do + archive_export.update_column(:status, 'running') + end + + it 'renders 404', :aggregate_failures do + download_export + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq('message' => '404 Archive Export Not Found') + end + end + + context 'when export is finished' do + it 'renders 200 with CSV file', :aggregate_failures do + download_export + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to include 'Scanner Type,Scanner Name' + end + end + end + + describe 'permissions', :enable_admin_mode do + context 'for export author' do + before_all do + project.add_developer(user) + end + + it { expect { download_export }.to be_allowed_for(user) } + end + + context 'for other users' do + it { expect { download_export }.to be_denied_for(:admin) } + it { expect { download_export }.to be_denied_for(:owner).of(project) } + it { expect { download_export }.to be_denied_for(:maintainer).of(project) } + it { expect { download_export }.to be_denied_for(:developer).of(project) } + it { expect { download_export }.to be_denied_for(:auditor) } + it { expect { download_export }.to be_denied_for(:reporter).of(project) } + it { expect { download_export }.to be_denied_for(:guest).of(project) } + it { expect { download_export }.to be_denied_for(:anonymous) } + end + end + + it_behaves_like 'forbids access to vulnerability API endpoint in case of disabled features' do + before_all do + project.add_developer(user) + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/archival/archived_record_builder_service_spec.rb b/ee/spec/services/vulnerabilities/archival/archived_record_builder_service_spec.rb index ac6edd67a281fcbf0f1ffb26773e93ab73afeb0a..a834ba97ff4503985d929ceb0c7000c63eeb12ac 100644 --- a/ee/spec/services/vulnerabilities/archival/archived_record_builder_service_spec.rb +++ b/ee/spec/services/vulnerabilities/archival/archived_record_builder_service_spec.rb @@ -51,7 +51,12 @@ project: project, external_type: 'CWE', external_id: 'CWE-123', - name: 'CWE-123') + name: 'CWE-123'), + build(:vulnerabilities_identifier, + project: project, + external_type: 'OWASP', + external_id: 'A01:2021', + name: 'OWASP-A01:2021') ] vulnerability.vulnerability_read.dismissal_reason = :false_positive @@ -71,6 +76,7 @@ description: 'Test Description', cve_value: 'CVE-2018-1234', cwe_value: 'CWE-123', + other_identifiers: ['OWASP-A01:2021'], created_at: vulnerability.created_at.to_s, location: { class: 'com.gitlab.security_products.tests.App', diff --git a/ee/spec/services/vulnerabilities/archival/export/create_service_spec.rb b/ee/spec/services/vulnerabilities/archival/export/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1c75c4a3ca0875e1dcacc1935f8eb745ee529128 --- /dev/null +++ b/ee/spec/services/vulnerabilities/archival/export/create_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Archival::Export::CreateService, feature_category: :vulnerability_management do + describe '#execute' do + let_it_be_with_refind(:project) { create(:project) } + let_it_be_with_refind(:author) { create(:user) } + + let(:start_date) { 5.days.ago.to_date } + let(:end_date) { 2.days.ago.to_date } + let(:service_object) { described_class.new(project, author, start_date, end_date, format: :csv) } + + subject(:create_export) { service_object.execute } + + before do + stub_licensed_features(security_dashboard: true) + end + + context 'when the user does not have permission to create export' do + it 'raises AccessDenied error' do + expect { create_export }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + + context 'when the user has permission to create export' do + before_all do + project.add_developer(author) + end + + before do + allow(Vulnerabilities::Archival::Export::ExportWorker).to receive(:perform_async) + end + + it 'creates a new export record in the database' do + expect { create_export }.to change { Vulnerabilities::ArchiveExport.count }.by(1) + end + + it 'creates the export record with correct attributes and returns it' do + expect(create_export).to have_attributes( + project_id: project.id, + author_id: author.id, + date_range: (start_date..end_date), + format: 'csv' + ) + end + + it 'schedules the export generation' do + create_export + + expect(Vulnerabilities::Archival::Export::ExportWorker).to have_received(:perform_async) + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/archival/export/export_service_spec.rb b/ee/spec/services/vulnerabilities/archival/export/export_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f86f02e71ff2ae5b2373340383b5d1c1ef123903 --- /dev/null +++ b/ee/spec/services/vulnerabilities/archival/export/export_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Archival::Export::ExportService, feature_category: :vulnerability_management do + describe '.export' do + let(:mock_archive_export) { instance_double(Vulnerabilities::ArchiveExport) } + let(:mock_service_object) { instance_spy(described_class) } + + subject(:export) { described_class.export(mock_archive_export) } + + before do + allow(described_class).to receive(:new).with(mock_archive_export).and_return(mock_service_object) + end + + it 'allocates a service object and delegates the call to it' do + export + + expect(mock_service_object).to have_received(:export) + end + end + + describe '#export' do + let(:archive_export) { create(:vulnerability_archive_export) } + let(:archive_1) { create(:vulnerability_archive) } + let(:archive_2) { create(:vulnerability_archive) } + let(:service_object) { described_class.new(archive_export) } + + subject(:export) { service_object.export } + + around do |example| + travel_to('23/04/2025') { example.run } + end + + before do + allow(archive_export).to receive(:archives).and_return([archive_1, archive_2]) + allow(archive_export.project).to receive(:full_path).and_return('full_path') + allow(Vulnerabilities::Archival::Export::PurgeWorker).to receive(:perform_in) + + create(:vulnerability_archived_record, archive: archive_1) + create(:vulnerability_archived_record, archive: archive_2) + end + + it 'creates the export file' do + expect { export }.to change { archive_export.reload.status }.to('finished') + .and change { archive_export.file.file }.from(nil) + end + + it 'sets the correct filename for the export' do + expected_file_name = 'full_path_vulnerabilities_archive_2025-04-18..2025-04-23_2025-04-23T0000.csv' + + expect { export }.to change { archive_export.file.filename }.from(nil).to(expected_file_name) + end + + it 'creates the export for all archived records' do + export + + csv = CSV.parse(archive_export.file.read, headers: true) + + expect(csv.length).to be(2) + end + + it 'schedules the deletion of the export' do + export + + expect(Vulnerabilities::Archival::Export::PurgeWorker) + .to have_received(:perform_in).with(24.hours, archive_export.id) + end + + context 'when an error happens' do + before do + allow(Vulnerabilities::Archival::Export::Exporters::CsvService).to receive(:new).and_raise('Foo') + end + + it 'does not change the status of the export and propagates the error' do + expect { export }.to raise_error('Foo') + .and not_change { archive_export.reload.status }.from('created') + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/archival/export/exporters/csv_service_spec.rb b/ee/spec/services/vulnerabilities/archival/export/exporters/csv_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..20e0f8c6fa1339900b7e8cab4d1f52cb18c9089e --- /dev/null +++ b/ee/spec/services/vulnerabilities/archival/export/exporters/csv_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Archival::Export::Exporters::CsvService, feature_category: :vulnerability_management do + describe '#generate' do + let_it_be(:archived_record) { create(:vulnerability_archived_record) } + + let(:export_csv_service) { described_class.new([archived_record.data]) } + + context 'when block is not given' do + it 'renders csv to string' do + expect(export_csv_service.generate).to be_a(String) + end + end + + context 'when block is given' do + it 'returns handle to Tempfile' do + expect(export_csv_service.generate { |file| file }).to be_a(Tempfile) + end + end + + describe 'CSV content' do + let(:csv) { CSV.parse(export_csv_service.generate, headers: true) } + + describe 'headers' do + let(:expected_headers) do + ['Tool', 'Scanner Name', 'Status', 'Vulnerability', 'Details', 'Severity', 'CVE', 'CWE', 'Other Identifiers', + 'Detected At', 'Location', 'Activity', 'Comments', 'Full Path', 'CVSS Vectors', 'Dismissal Reason'] + end + + it 'contains the expected headers' do + expect(csv.headers).to eq(expected_headers) + end + end + + describe 'rows' do + it 'serializes correct number of rows' do + expect(csv.length).to be(1) + end + + it 'serializes the correct content' do + expect(csv[0].to_h).to match( + { + 'Tool' => 'sast', + 'Scanner Name' => 'Find Security Bugs', + 'Status' => 'dismissed', + 'Vulnerability' => 'Test Title', + 'Details' => 'Test Description', + 'Severity' => 'high', + 'CVE' => 'CVE-2018-1234', + 'CWE' => 'CWE-123', + 'Other Identifiers' => 'OWASP-A01:2021', + 'Detected At' => '2025-01-29 19:02:08 UTC', + 'Location' => '{"class"=>"com.gitlab.security_products.tests.App", "end_line"=>29, ' \ + '"file"=>"maven/src/main/java/com/gitlab/security_products/tests/App.java", ' \ + '"method"=>"insecureCypher", "start_line"=>29}', + 'Activity' => 'false', + 'Comments' => 'Test notes summary', + 'Full Path' => 'Test full path', + 'CVSS Vectors' => 'GitLab=CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:N', + 'Dismissal Reason' => 'False positive' + } + ) + end + end + end + end +end diff --git a/ee/spec/services/vulnerabilities/archival/export/purge_service_spec.rb b/ee/spec/services/vulnerabilities/archival/export/purge_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0b3e9d1c67ebb8d5d3615986c87f2b7dc16e9a31 --- /dev/null +++ b/ee/spec/services/vulnerabilities/archival/export/purge_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Archival::Export::PurgeService, feature_category: :vulnerability_management do + describe '.purge' do + let(:archive_export) { create(:vulnerability_archive_export, :with_csv_file) } + + subject(:purge) { described_class.purge(archive_export) } + + it 'changes the status of the record' do + expect { purge }.to change { archive_export.reload.status }.to('purged') + end + + it 'deletes the file associated with the record' do + expect { purge }.to change { archive_export.reload.file.file }.to(nil) + end + end +end diff --git a/ee/spec/workers/vulnerabilities/archival/export/export_worker_spec.rb b/ee/spec/workers/vulnerabilities/archival/export/export_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5e379d09356a0002ae6a4770c6ba84a68ead08c6 --- /dev/null +++ b/ee/spec/workers/vulnerabilities/archival/export/export_worker_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Archival::Export::ExportWorker, feature_category: :vulnerability_management do + describe '.sidekiq_retries_exhausted' do + let(:archive_export_id) { 1 } + let(:job) { { 'args' => [1] } } + + subject(:retries_exhausted) { described_class.sidekiq_retries_exhausted_block.call(job) } + + before do + allow(Vulnerabilities::Archival::Export::PurgeWorker).to receive(:perform_in) + end + + it 'schedules the purge job' do + retries_exhausted + + expect(Vulnerabilities::Archival::Export::PurgeWorker) + .to have_received(:perform_in).with(24.hours, archive_export_id) + end + end + + describe '#perform' do + let(:worker) { described_class.new } + + subject(:perform_worker) { worker.perform(archive_export_id) } + + before do + allow(Vulnerabilities::Archival::Export::ExportService).to receive(:export) + end + + context 'when there is no record for the given ID' do + let(:archive_export_id) { non_existing_record_id } + + it 'does not run the service layer logic' do + perform_worker + + expect(Vulnerabilities::Archival::Export::ExportService).not_to have_received(:export) + end + end + + context 'when there is a record for the given ID' do + let(:archive_export) { create(:vulnerability_archive_export) } + let(:archive_export_id) { archive_export.id } + + it 'runs the service layer logic' do + perform_worker + + expect(Vulnerabilities::Archival::Export::ExportService).to have_received(:export) + end + end + end +end diff --git a/ee/spec/workers/vulnerabilities/archival/export/purge_worker_spec.rb b/ee/spec/workers/vulnerabilities/archival/export/purge_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b59aee5ac4c46289694faba5c4caff7a97cd6a1 --- /dev/null +++ b/ee/spec/workers/vulnerabilities/archival/export/purge_worker_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Vulnerabilities::Archival::Export::PurgeWorker, feature_category: :vulnerability_management do + describe '#perform' do + let(:worker) { described_class.new } + + subject(:perform_worker) { worker.perform(archive_export_id) } + + before do + allow(Vulnerabilities::Archival::Export::PurgeService).to receive(:purge) + end + + context 'when there is no record for the given ID' do + let(:archive_export_id) { non_existing_record_id } + + it 'does not run the service layer logic' do + perform_worker + + expect(Vulnerabilities::Archival::Export::PurgeService).not_to have_received(:purge) + end + end + + context 'when there is a record for the given ID' do + let(:archive_export) { create(:vulnerability_archive_export) } + let(:archive_export_id) { archive_export.id } + + it 'runs the service layer logic' do + perform_worker + + expect(Vulnerabilities::Archival::Export::PurgeService).to have_received(:purge) + end + end + end +end