From 037bd59644010b04429662e18585c959c3ae38f1 Mon Sep 17 00:00:00 2001
From: mo khan <mo@mokhan.ca>
Date: Fri, 1 Mar 2024 18:39:04 +0000
Subject: [PATCH] Add Export to Explore Dependency list page

---
 ...anization_id_to_dependency_list_exports.rb |   9 ++
 ...anization_id_on_dependency_list_exports.rb |  16 +++
 ...anization_id_on_dependency_list_exports.rb |  17 +++
 db/schema_migrations/20240206224725           |   1 +
 db/schema_migrations/20240206225046           |   1 +
 db/schema_migrations/20240208235322           |   1 +
 db/structure.sql                              |   6 +
 .../dependencies/dependency_list_export.rb    |  31 ++---
 .../organization_dependencies_service.rb      |  55 ++++++++
 .../services/dependencies/export_service.rb   |  13 +-
 .../explore/dependencies/index.html.haml      |   2 +-
 ee/lib/api/dependency_list_exports.rb         |  18 +++
 .../dependency_list_export_spec.rb            | 126 +++++++-----------
 .../api/dependency_list_exports_spec.rb       |  37 +++++
 .../organization_dependencies_service_spec.rb |  64 +++++++++
 .../sbom/pipeline_service_spec.rb             |   2 +-
 .../dependencies/export_service_spec.rb       |  44 +++++-
 17 files changed, 334 insertions(+), 109 deletions(-)
 create mode 100644 db/migrate/20240206224725_add_organization_id_to_dependency_list_exports.rb
 create mode 100644 db/post_migrate/20240206225046_index_organization_id_on_dependency_list_exports.rb
 create mode 100644 db/post_migrate/20240208235322_add_foreign_key_to_organization_id_on_dependency_list_exports.rb
 create mode 100644 db/schema_migrations/20240206224725
 create mode 100644 db/schema_migrations/20240206225046
 create mode 100644 db/schema_migrations/20240208235322
 create mode 100644 ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb
 create mode 100644 ee/spec/services/dependencies/export_serializers/organization_dependencies_service_spec.rb

diff --git a/db/migrate/20240206224725_add_organization_id_to_dependency_list_exports.rb b/db/migrate/20240206224725_add_organization_id_to_dependency_list_exports.rb
new file mode 100644
index 0000000000000..153c03ad6d23c
--- /dev/null
+++ b/db/migrate/20240206224725_add_organization_id_to_dependency_list_exports.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddOrganizationIdToDependencyListExports < Gitlab::Database::Migration[2.2]
+  milestone '16.10'
+
+  def change
+    add_column :dependency_list_exports, :organization_id, :bigint
+  end
+end
diff --git a/db/post_migrate/20240206225046_index_organization_id_on_dependency_list_exports.rb b/db/post_migrate/20240206225046_index_organization_id_on_dependency_list_exports.rb
new file mode 100644
index 0000000000000..a5e37f1adacfd
--- /dev/null
+++ b/db/post_migrate/20240206225046_index_organization_id_on_dependency_list_exports.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class IndexOrganizationIdOnDependencyListExports < Gitlab::Database::Migration[2.2]
+  INDEX_NAME = 'index_dependency_list_exports_on_organization_id'
+
+  disable_ddl_transaction!
+  milestone '16.10'
+
+  def up
+    add_concurrent_index :dependency_list_exports, :organization_id, name: INDEX_NAME
+  end
+
+  def down
+    remove_concurrent_index_by_name :dependency_list_exports, INDEX_NAME
+  end
+end
diff --git a/db/post_migrate/20240208235322_add_foreign_key_to_organization_id_on_dependency_list_exports.rb b/db/post_migrate/20240208235322_add_foreign_key_to_organization_id_on_dependency_list_exports.rb
new file mode 100644
index 0000000000000..c4a4d869f6b23
--- /dev/null
+++ b/db/post_migrate/20240208235322_add_foreign_key_to_organization_id_on_dependency_list_exports.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddForeignKeyToOrganizationIdOnDependencyListExports < Gitlab::Database::Migration[2.2]
+  disable_ddl_transaction!
+  milestone '16.10'
+
+  def up
+    add_concurrent_foreign_key :dependency_list_exports, :organizations,
+      column: :organization_id,
+      on_delete: :cascade,
+      reverse_lock_order: true
+  end
+
+  def down
+    remove_foreign_key_if_exists :dependency_list_exports, column: :organization_id
+  end
+end
diff --git a/db/schema_migrations/20240206224725 b/db/schema_migrations/20240206224725
new file mode 100644
index 0000000000000..edd8af605411a
--- /dev/null
+++ b/db/schema_migrations/20240206224725
@@ -0,0 +1 @@
+ba88b86b5331d02fa7cfa4416fc2de5dbd451dc1fc03f5c6550f981b91d7ed96
\ No newline at end of file
diff --git a/db/schema_migrations/20240206225046 b/db/schema_migrations/20240206225046
new file mode 100644
index 0000000000000..23e6557599453
--- /dev/null
+++ b/db/schema_migrations/20240206225046
@@ -0,0 +1 @@
+d1c427131b7cddcab2891069a03af3c615e6126f3d7a3a5a8e6b30b7f1875d5e
\ No newline at end of file
diff --git a/db/schema_migrations/20240208235322 b/db/schema_migrations/20240208235322
new file mode 100644
index 0000000000000..5cd023fda08b4
--- /dev/null
+++ b/db/schema_migrations/20240208235322
@@ -0,0 +1 @@
+0e53c0f6004aad8fee7323e5652dbabc9823561dd73ed017c7bfa34678ff9527
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 241efc083256c..098dfae7223ed 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -7759,6 +7759,7 @@ CREATE TABLE dependency_list_exports (
     group_id bigint,
     pipeline_id bigint,
     export_type smallint DEFAULT 0 NOT NULL,
+    organization_id bigint,
     CONSTRAINT check_fff6fc9b2f CHECK ((char_length(file) <= 255))
 );
 
@@ -24684,6 +24685,8 @@ CREATE UNIQUE INDEX index_dep_prox_manifests_on_group_id_file_name_and_status ON
 
 CREATE INDEX index_dependency_list_exports_on_group_id ON dependency_list_exports USING btree (group_id);
 
+CREATE INDEX index_dependency_list_exports_on_organization_id ON dependency_list_exports USING btree (organization_id);
+
 CREATE INDEX index_dependency_list_exports_on_pipeline_id ON dependency_list_exports USING btree (pipeline_id);
 
 CREATE INDEX index_dependency_list_exports_on_project_id ON dependency_list_exports USING btree (project_id);
@@ -30111,6 +30114,9 @@ ALTER TABLE ONLY sbom_occurrences
 ALTER TABLE ONLY issues
     ADD CONSTRAINT fk_c34dd2b036 FOREIGN KEY (tmp_epic_id) REFERENCES epics(id) ON DELETE CASCADE;
 
+ALTER TABLE ONLY dependency_list_exports
+    ADD CONSTRAINT fk_c348f16f10 FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY user_group_callouts
     ADD CONSTRAINT fk_c366e12ec3 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
 
diff --git a/ee/app/models/dependencies/dependency_list_export.rb b/ee/app/models/dependencies/dependency_list_export.rb
index a45a7763bd306..d976844fae922 100644
--- a/ee/app/models/dependencies/dependency_list_export.rb
+++ b/ee/app/models/dependencies/dependency_list_export.rb
@@ -5,6 +5,7 @@ class DependencyListExport < ApplicationRecord
 
     mount_file_store_uploader AttachmentUploader
 
+    belongs_to :organization, class_name: 'Organizations::Organization'
     belongs_to :project
     belongs_to :group
     belongs_to :pipeline, class_name: 'Ci::Pipeline'
@@ -49,17 +50,19 @@ def retrieve_upload(_identifier, paths)
     end
 
     def exportable
-      pipeline || project || group
+      pipeline || project || group || organization
     end
 
     def exportable=(value)
       case value
       when Project
-        make_project_level_export(value)
+        self.project = value
       when Group
-        make_group_level_export(value)
+        self.group = value
+      when Organizations::Organization
+        self.organization = value
       when Ci::Pipeline
-        make_pipeline_level_export(value)
+        self.pipeline = value
       else
         raise "Can not assign #{value.class} as exportable"
       end
@@ -67,26 +70,8 @@ def exportable=(value)
 
     private
 
-    def make_project_level_export(project)
-      self.project = project
-      self.group = nil
-      self.pipeline = nil
-    end
-
-    def make_group_level_export(group)
-      self.project = nil
-      self.group = group
-      self.pipeline = nil
-    end
-
-    def make_pipeline_level_export(pipeline)
-      self.project = nil
-      self.group = nil
-      self.pipeline = pipeline
-    end
-
     def only_one_exportable
-      errors.add(:base, 'Only one exportable is required') unless [project, group, pipeline].one?
+      errors.add(:base, 'Only one exportable is required') unless [project, group, pipeline, organization].one?
     end
   end
 end
diff --git a/ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb b/ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb
new file mode 100644
index 0000000000000..4194623778c00
--- /dev/null
+++ b/ee/app/services/dependencies/export_serializers/organization_dependencies_service.rb
@@ -0,0 +1,55 @@
+# 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
+
+        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 each_batch
+        Gitlab::Pagination::Keyset::Iterator
+          .new(scope: export.organization.sbom_occurrences)
+          .each_batch { |batch| yield batch }
+      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_service.rb b/ee/app/services/dependencies/export_service.rb
index dfd340f5a40f0..b9da33fe266a7 100644
--- a/ee/app/services/dependencies/export_service.rb
+++ b/ee/app/services/dependencies/export_service.rb
@@ -4,6 +4,7 @@ module Dependencies
   class ExportService
     SERIALIZER_SERVICES = {
       dependency_list: {
+        Organizations::Organization => ExportSerializers::OrganizationDependenciesService,
         Project => ExportSerializers::ProjectDependenciesService,
         Group => ExportSerializers::GroupDependenciesService
       },
@@ -36,7 +37,17 @@ def execute
     def create_export
       dependency_list_export.start!
 
-      create_export_file
+      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
 
       dependency_list_export.finish!
     rescue StandardError, Dependencies::ExportSerializers::Sbom::PipelineService::SchemaValidationError
diff --git a/ee/app/views/explore/dependencies/index.html.haml b/ee/app/views/explore/dependencies/index.html.haml
index 67a84e0577168..2ed4ae9bd1df5 100644
--- a/ee/app/views/explore/dependencies/index.html.haml
+++ b/ee/app/views/explore/dependencies/index.html.haml
@@ -6,7 +6,7 @@
     endpoint: explore_dependencies_path(format: :json),
     licenses_endpoint: nil,
     locations_endpoint: nil,
-    export_endpoint: nil,
+    export_endpoint: expose_path(api_v4_organizations_dependency_list_exports_path(id: @organization.id)),
     vulnerabilities_endpoint: nil,
     documentation_path: help_page_path('user/application_security/dependency_list/index'),
     support_documentation_path: help_page_path('user/application_security/dependency_scanning/index', anchor: 'supported-languages-and-package-managers'),
diff --git a/ee/lib/api/dependency_list_exports.rb b/ee/lib/api/dependency_list_exports.rb
index b79a6067c36a2..dfbe6dc0bdb5a 100644
--- a/ee/lib/api/dependency_list_exports.rb
+++ b/ee/lib/api/dependency_list_exports.rb
@@ -37,6 +37,24 @@ class DependencyListExports < ::API::Base
       end
     end
 
+    resource :organizations do
+      params do
+        requires :id, types: [String, Integer], desc: 'The ID of the organization'
+      end
+      desc 'Generate a dependency list export on an organization-level'
+      post ':id/dependency_list_exports' do
+        not_found! unless Feature.enabled?(:explore_dependencies, current_user)
+
+        organization = find_organization!(params[:id])
+        authorize! :read_dependency, organization
+
+        export = ::Dependencies::CreateExportService
+          .new(organization, current_user)
+          .execute
+        present export, with: EE::API::Entities::DependencyListExport
+      end
+    end
+
     resource :pipelines do
       params do
         requires :id, types: [String, Integer], desc: 'The ID of the pipeline'
diff --git a/ee/spec/models/dependencies/dependency_list_export_spec.rb b/ee/spec/models/dependencies/dependency_list_export_spec.rb
index 8edaa81cd398e..26a180589ec04 100644
--- a/ee/spec/models/dependencies/dependency_list_export_spec.rb
+++ b/ee/spec/models/dependencies/dependency_list_export_spec.rb
@@ -27,6 +27,8 @@
     end
 
     describe 'only one exportable can be set' do
+      using RSpec::Parameterized::TableSyntax
+
       let(:expected_error) { { error: 'Only one exportable is required' } }
 
       subject { export.errors.details[:base] }
@@ -35,52 +37,40 @@
         export.validate
       end
 
-      context 'when project and group is set' do
-        let(:export) { build(:dependency_list_export, project: project, group: group) }
-
-        it { is_expected.to include(expected_error) }
-      end
-
-      context 'when project and pipeline is set' do
-        let(:export) { build(:dependency_list_export, project: project, pipeline: pipeline) }
-
-        it { is_expected.to include(expected_error) }
-      end
-
-      context 'when pipeline and group is set' do
-        let(:export) { build(:dependency_list_export, pipeline: pipeline, group: group) }
-
-        it { is_expected.to include(expected_error) }
-      end
-
-      context 'when project, group and pipeline is set' do
-        let(:export) { build(:dependency_list_export, project: project, group: group, pipeline: pipeline) }
-
-        it { is_expected.to include(expected_error) }
-      end
-
-      context 'when none is set' do
-        let(:export) { build(:dependency_list_export, project: nil, group: nil, pipeline: nil) }
-
-        it { is_expected.to include(expected_error) }
-      end
-
-      context 'when only project is set' do
-        let(:export) { build(:dependency_list_export, project: project, group: nil) }
-
-        it { is_expected.not_to include(expected_error) }
-      end
-
-      context 'when only group is set' do
-        let(:export) { build(:dependency_list_export, project: nil, group: group, pipeline: nil) }
-
-        it { is_expected.not_to include(expected_error) }
-      end
-
-      context 'when only pipeline is set' do
-        let(:export) { build(:dependency_list_export, project: nil, group: nil, pipeline: pipeline) }
-
-        it { is_expected.not_to include(expected_error) }
+      where(:args, :valid) do
+        organization = build_stubbed(:organization)
+        group = build_stubbed(:group, organization: organization)
+        project = build_stubbed(:project, organization: organization, group: group)
+        pipeline = build_stubbed(:ci_pipeline, project: project)
+
+        [
+          [{ organization: organization, group: group, project: project, pipeline: pipeline }, false],
+          [{ organization: organization, group: group, project: project, pipeline: nil }, false],
+          [{ organization: organization, group: group, project: nil, pipeline: pipeline }, false],
+          [{ organization: organization, group: group, project: nil, pipeline: nil }, false],
+          [{ organization: organization, group: nil, project: project, pipeline: pipeline }, false],
+          [{ organization: organization, group: nil, project: project, pipeline: nil }, false],
+          [{ organization: organization, group: nil, project: nil, pipeline: pipeline }, false],
+          [{ organization: organization, group: nil, project: nil, pipeline: nil }, true],
+          [{ organization: nil, group: group, project: project, pipeline: pipeline }, false],
+          [{ organization: nil, group: group, project: project, pipeline: nil }, false],
+          [{ organization: nil, group: group, project: nil, pipeline: pipeline }, false],
+          [{ organization: nil, group: group, project: nil, pipeline: nil }, true],
+          [{ organization: nil, group: nil, project: project, pipeline: pipeline }, false],
+          [{ organization: nil, group: nil, project: project, pipeline: nil }, true],
+          [{ organization: nil, group: nil, project: nil, pipeline: pipeline }, true],
+          [{ organization: nil, group: nil, project: nil, pipeline: nil }, false]
+        ]
+      end
+
+      with_them do
+        let(:export) { build(:dependency_list_export, args) }
+
+        if params[:valid]
+          it { is_expected.not_to include(expected_error) }
+        else
+          it { is_expected.to include(expected_error) }
+        end
       end
     end
   end
@@ -177,42 +167,18 @@
   end
 
   describe '#exportable=' do
-    context 'when the given argument is a project' do
-      let(:export) { build(:dependency_list_export, group: group, pipeline: pipeline) }
-
-      it 'assigns the project and unassigns the group' do
-        expect { export.exportable = project }.to change { export.project }.to(project)
-                                              .and change { export.group }.to(nil)
-                                              .and change { export.pipeline }.to(nil)
-      end
-    end
-
-    context 'when the given argument is a group' do
-      let(:export) { build(:dependency_list_export, project: project, pipeline: pipeline) }
-
-      it 'assigns the group and unassigns the project' do
-        expect { export.exportable = group }.to change { export.group }.to(group)
-                                            .and change { export.project }.to(nil)
-                                            .and change { export.pipeline }.to(nil)
-      end
+    let(:export) { build(:dependency_list_export) }
+    let(:organization) { build_stubbed(:organization) }
+
+    it 'sets the correct association' do
+      expect { export.exportable = project }.to change { export.project }.to(project)
+      expect { export.exportable = group }.to change { export.group }.to(group)
+      expect { export.exportable = pipeline }.to change { export.pipeline }.to(pipeline)
+      expect { export.exportable = organization }.to change { export.organization }.to(organization)
     end
 
-    context 'when the given argument is a pipeline' do
-      let(:export) { build(:dependency_list_export, group: group, project: project) }
-
-      it 'assigns the pipeline and unassigns the group' do
-        expect { export.exportable = pipeline }.to change { export.pipeline }.to(pipeline)
-                                            .and change { export.project }.to(nil)
-                                            .and change { export.group }.to(nil)
-      end
-    end
-
-    context 'when the given argument is neither a project, group or pipeline' do
-      let(:export) { build(:dependency_list_export) }
-
-      it 'raises an error' do
-        expect { export.exportable = nil }.to raise_error(RuntimeError)
-      end
+    it 'raises when exportable is an unknown type' do
+      expect { export.exportable = nil }.to raise_error(RuntimeError)
     end
   end
 end
diff --git a/ee/spec/requests/api/dependency_list_exports_spec.rb b/ee/spec/requests/api/dependency_list_exports_spec.rb
index a2798b62debe5..4a5b630ff4635 100644
--- a/ee/spec/requests/api/dependency_list_exports_spec.rb
+++ b/ee/spec/requests/api/dependency_list_exports_spec.rb
@@ -74,6 +74,43 @@
     end
   end
 
+  describe 'POST /organizations/:id/dependency_list_exports' do
+    let_it_be(:organization) { create(:organization) }
+
+    context 'when admin mode is enabled', :enable_admin_mode do
+      context 'when the user is an admin' do
+        let_it_be(:current_user) { create(:user, :admin) }
+
+        before do
+          stub_licensed_features(dependency_scanning: true, security_dashboard: true)
+        end
+
+        it 'generates an export' do
+          post api("/organizations/#{organization.id}/dependency_list_exports", current_user)
+
+          expect(response).to have_gitlab_http_status(:created)
+          expect(json_response).to be_present
+          expect(json_response).to have_key('id')
+          expect(json_response).to have_key('has_finished')
+          expect(json_response).to have_key('self')
+          expect(json_response).to have_key('download')
+        end
+
+        context 'when the `explore_dependencies` feature flag is disabled' do
+          before do
+            stub_feature_flags(explore_dependencies: false)
+          end
+
+          it 'does not generate an export' do
+            post api("/organizations/#{organization.id}/dependency_list_exports", current_user)
+
+            expect(response).to have_gitlab_http_status(:not_found)
+          end
+        end
+      end
+    end
+  end
+
   describe 'POST /projects/:id/dependency_list_exports' do
     let(:request_path) { "/projects/#{project.id}/dependency_list_exports" }
     let(:resource) { project }
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
new file mode 100644
index 0000000000000..a1d93ec7d70d7
--- /dev/null
+++ b/ee/spec/services/dependencies/export_serializers/organization_dependencies_service_spec.rb
@@ -0,0 +1,64 @@
+# 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_with_reload(:project) { create(:project, organization: organization) }
+  let_it_be(:export) { create(:dependency_list_export, project: nil, organization: organization) }
+
+  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
+
+      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
+
+      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 994d5f9e962e9..d93818209b95e 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
@@ -20,7 +20,7 @@
   end
 
   describe '#execute' do
-    let(:dependency_list_export) { create(:dependency_list_export, exportable: pipeline) }
+    let(:dependency_list_export) { create(:dependency_list_export, project: nil, exportable: pipeline) }
 
     let(:service_class) { described_class.new(dependency_list_export) }
 
diff --git a/ee/spec/services/dependencies/export_service_spec.rb b/ee/spec/services/dependencies/export_service_spec.rb
index 9fd853d985bd0..279aa2019dd14 100644
--- a/ee/spec/services/dependencies/export_service_spec.rb
+++ b/ee/spec/services/dependencies/export_service_spec.rb
@@ -83,8 +83,41 @@
     end
 
     context 'when export type is dependency_list' do
+      let(:timestamp) { Time.current.utc.strftime('%FT%H%M') }
       let(:export_type) { :dependency_list }
 
+      context 'when the exportable is an organization' do
+        subject(:execute) { described_class.new(export).execute }
+
+        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(: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 { export.file.filename }.to(expected_filename) }
+
+        it 'includes a header in the export file' do
+          header = '"Name","Version","Packager","Location"'
+          expect { execute }.to change { export.file.read }.to(include(header))
+        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
+      end
+
       context 'when the exportable is a project' do
         let_it_be(:project) { create(:project) }
 
@@ -100,7 +133,7 @@
 
         it_behaves_like 'export service', Dependencies::ExportSerializers::ProjectDependenciesService do
           let(:dependency_list_export) do
-            create(:dependency_list_export, exportable: project, status: status, export_type: export_type)
+            create(:dependency_list_export, project: nil, exportable: project, status: status, export_type: export_type)
           end
         end
       end
@@ -120,7 +153,7 @@
 
         it_behaves_like 'export service', Dependencies::ExportSerializers::GroupDependenciesService do
           let(:dependency_list_export) do
-            create(:dependency_list_export, exportable: group, status: status, export_type: export_type)
+            create(:dependency_list_export, project: nil, exportable: group, status: status, export_type: export_type)
           end
         end
       end
@@ -148,7 +181,12 @@
 
         it_behaves_like 'export service', Dependencies::ExportSerializers::Sbom::PipelineService do
           let(:dependency_list_export) do
-            create(:dependency_list_export, exportable: pipeline, status: status, export_type: export_type)
+            create(:dependency_list_export, {
+              project: nil,
+              exportable: pipeline,
+              status: status,
+              export_type: export_type
+            })
           end
         end
       end
-- 
GitLab