From 9fafff194b34691524cde2bbdf6b94d8d5202204 Mon Sep 17 00:00:00 2001
From: Michael Becker <11881043-wandering_person@users.noreply.gitlab.com>
Date: Wed, 30 Aug 2023 17:03:01 +0000
Subject: [PATCH] Change `Vulnerability*Finder` interaction with archived
 projects

In [issue 213144][0] we defined new default expected behavior. We want
the default behavior for all finders to be:

Do not include vulnerability` or `vulnerability_read` objects in the
result-set if the project they are associated with is currently
`archived`

At the same time we want to, in a followup commit, provide API access
to toggle on/off the inclusion of `archived` objects

While implementing this behavior the following behavior was
encountered:

1. `vulnerability_read` vs `vulnerability` differences

    The [existing behavior][1] of `Group.vulnerabilities` is to ignore
    vulnerabilities associated with archived projects.

    The [existing behavior][2] of `Group.vulnerability_reads` does not
    necessarily account for the archived status of associated
    projects

    This commit unifies both to ignoring archive status, leaving that
    to be done in filters for the finders.

[0]:https://gitlab.com/gitlab-org/gitlab/-/issues/213144
[1]:https://gitlab.com/gitlab-org/gitlab/-/blob/45e01fad9dffb4c447bf701e040d552077f60dac/ee/app/models/ee/group.rb#L555-557
[2]:https://gitlab.com/gitlab-org/gitlab/-/blob/45e01fad9dffb4c447bf701e040d552077f60dac/ee/app/models/ee/group.rb#L559-561

Changelog: fixed
EE: true
---
 .../security/vulnerabilities_finder.rb        | 20 +++++-
 .../security/vulnerability_reads_finder.rb    | 20 +++++-
 .../models/concerns/vulnerability_scopes.rb   |  3 +-
 ee/app/models/ee/group.rb                     |  4 +-
 .../security/vulnerabilities_finder_spec.rb   | 64 +++++++++++++++++--
 .../vulnerability_reads_finder_spec.rb        | 64 +++++++++++++++++--
 ee/spec/models/ee/group_spec.rb               |  4 +-
 ...lnerability_and_finding_shared_examples.rb | 40 +++++++-----
 8 files changed, 183 insertions(+), 36 deletions(-)

diff --git a/ee/app/finders/security/vulnerabilities_finder.rb b/ee/app/finders/security/vulnerabilities_finder.rb
index 342d486d3442e..015a52171a97a 100644
--- a/ee/app/finders/security/vulnerabilities_finder.rb
+++ b/ee/app/finders/security/vulnerabilities_finder.rb
@@ -9,6 +9,8 @@
 #   params: optional! a hash with one or more of the following:
 #     project_ids: if `vulnerable` includes multiple projects (like a Group), this filter will restrict
 #                   the vulnerabilities returned to those in the group's projects that also match these IDs
+#     include_archived_projects: defaulted to `false`. Determines if results will include vulnerabilities
+#                                 associated with archived projects
 #     image: only return vulnerabilities with these location images
 #     report_types: only return vulnerabilities from these report types
 #     severities: only return vulnerabilities with these severities
@@ -22,7 +24,7 @@ class VulnerabilitiesFinder
     include FinderMethods
 
     def initialize(vulnerable, params = {})
-      @params = params
+      @params = { include_archived_projects: false }.merge(params)
       @vulnerable = vulnerable
       @vulnerabilities = vulnerable.vulnerabilities
     end
@@ -31,6 +33,7 @@ def execute
       # As we are creating vulnerability with default branch set to false irrespective of feature flag
       # from user interaction (issue/mr creation and dismissal of finding), we always need to do this filtering
       filter_by_present_on_default_branch
+      filter_archived_projects
       filter_by_projects
       filter_by_image
       filter_by_report_types
@@ -58,9 +61,22 @@ def filter_by_present_on_default_branch
                          end
     end
 
+    def filter_archived_projects
+      return if params[:include_archived_projects] == true
+      return unless vulnerable.is_a?(Group)
+
+      # `filter_by_projects` will handle archived projects
+      return if params[:project_id].present?
+
+      @vulnerabilities = vulnerabilities.without_archived_projects
+    end
+
     def filter_by_projects
       if params[:project_id].present?
-        @vulnerabilities = vulnerabilities.for_projects(params[:project_id])
+        @vulnerabilities = vulnerabilities.for_projects(
+          params[:project_id],
+          params[:include_archived_projects]
+        )
       end
     end
 
diff --git a/ee/app/finders/security/vulnerability_reads_finder.rb b/ee/app/finders/security/vulnerability_reads_finder.rb
index dcc2753d86803..f8d06217b76ab 100644
--- a/ee/app/finders/security/vulnerability_reads_finder.rb
+++ b/ee/app/finders/security/vulnerability_reads_finder.rb
@@ -9,6 +9,8 @@
 #   params: optional! a hash with one or more of the following:
 #     project_ids: if `vulnerable` includes multiple projects (like a Group), this filter will restrict
 #                   the vulnerabilities returned to those in the group's projects that also match these IDs
+#     include_archived_projects: defaulted to `false`. Determines if results will include vulnerabilities
+#                                 associated with archived projects
 #     image: only return vulnerabilities with these location images
 #     report_types: only return vulnerabilities from these report types
 #     severities: only return vulnerabilities with these severities
@@ -25,13 +27,14 @@ class VulnerabilityReadsFinder
     include FinderMethods
 
     def initialize(vulnerable, params = {})
-      @params = params
+      @params = { include_archived_projects: false }.merge(params)
       @vulnerable = vulnerable
       @vulnerability_reads = vulnerable.vulnerability_reads
     end
 
     def execute
       use_unnested_filters
+      filter_archived_projects
       filter_by_projects
       filter_by_image
       filter_by_report_types
@@ -60,10 +63,23 @@ def use_unnested_filters?
       vulnerable.is_a?(Project) || vulnerable.is_a?(Group)
     end
 
+    def filter_archived_projects
+      return if params[:include_archived_projects] == true
+      return unless vulnerable.is_a?(Group)
+
+      # `filter_by_projects` will handle archived projects
+      return if params[:project_id].present?
+
+      @vulnerability_reads = vulnerability_reads.without_archived_projects
+    end
+
     def filter_by_projects
       return unless params[:project_id].present?
 
-      @vulnerability_reads = vulnerability_reads.for_projects(params[:project_id])
+      @vulnerability_reads = vulnerability_reads.for_projects(
+        params[:project_id],
+        params[:include_archived_projects]
+      )
     end
 
     def filter_by_report_types
diff --git a/ee/app/models/concerns/vulnerability_scopes.rb b/ee/app/models/concerns/vulnerability_scopes.rb
index ce660205e2f58..3556fa15068c4 100644
--- a/ee/app/models/concerns/vulnerability_scopes.rb
+++ b/ee/app/models/concerns/vulnerability_scopes.rb
@@ -4,11 +4,12 @@ module VulnerabilityScopes
   extend ActiveSupport::Concern
 
   included do
+    scope :without_archived_projects, -> { joins(:project).merge(::Project.non_archived) }
     scope :for_projects, ->(project_ids, include_archived = false) do
       if include_archived
         where(project_id: project_ids)
       else
-        joins(:project).merge(::Project.non_archived).where(project_id: project_ids)
+        without_archived_projects.where(project_id: project_ids)
       end
     end
   end
diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb
index 859515f2b208d..259207a01cc1c 100644
--- a/ee/app/models/ee/group.rb
+++ b/ee/app/models/ee/group.rb
@@ -556,7 +556,9 @@ def adjourned_deletion?
     end
 
     def vulnerabilities
-      ::Vulnerability.where(project: projects_for_group_and_its_subgroups_without_deleted)
+      ::Vulnerability.where(
+        project: ::Project.for_group_and_its_subgroups(self).without_deleted
+      )
     end
 
     def vulnerability_reads
diff --git a/ee/spec/finders/security/vulnerabilities_finder_spec.rb b/ee/spec/finders/security/vulnerabilities_finder_spec.rb
index 46f98a420b957..585c54ddcef89 100644
--- a/ee/spec/finders/security/vulnerabilities_finder_spec.rb
+++ b/ee/spec/finders/security/vulnerabilities_finder_spec.rb
@@ -2,8 +2,15 @@
 
 require 'spec_helper'
 
-RSpec.describe Security::VulnerabilitiesFinder do
+RSpec.describe Security::VulnerabilitiesFinder, feature_category: :vulnerability_management do
   let_it_be(:project) { create(:project) }
+  let_it_be(:group) { create(:group) }
+
+  let_it_be(:archived_project) do
+    create(:project, :archived, namespace: group).tap do |p|
+      create(:vulnerability, :with_finding, project: p)
+    end
+  end
 
   let_it_be(:vulnerability1) do
     create(:vulnerability, :with_findings, :with_issue_links, severity: :low, report_type: :sast, state: :detected, project: project)
@@ -19,6 +26,7 @@
 
   let(:filters) { {} }
   let(:vulnerable) { project }
+  let(:archive_associated_vulnerabilities) { archived_project.vulnerabilities }
 
   subject { described_class.new(vulnerable, filters).execute }
 
@@ -34,6 +42,33 @@
     end
   end
 
+  context 'when using the include_archived_projects param' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:result_including_archived) { result_excluding_archived + archive_associated_vulnerabilities }
+    let(:result_excluding_archived) { group.vulnerabilities.without_archived_projects }
+
+    before do
+      project.update!(namespace: group)
+    end
+
+    where(:vulnerable_object, :include_archived_projects, :result) do
+      ref(:archived_project) | true   | ref(:archive_associated_vulnerabilities)
+      ref(:archived_project) | false  | ref(:archive_associated_vulnerabilities)
+      ref(:group)            | true   | ref(:result_including_archived)
+      ref(:group)            | false  | ref(:result_excluding_archived)
+    end
+
+    with_them do
+      let(:vulnerable) { vulnerable_object }
+      let(:filters) { super().merge(include_archived_projects: include_archived_projects) }
+
+      it 'filters out vulnerabilities associated with archived projects as defined' do
+        expect(subject).to match_array(result)
+      end
+    end
+  end
+
   context 'when filtered by report type' do
     let(:filters) { { report_type: %w[sast dast] } }
 
@@ -74,19 +109,34 @@
     end
   end
 
-  context 'when filtered by project' do
-    let(:group) { create(:group) }
+  context 'when the vulnerable object is a Group' do
+    let(:vulnerable) { group }
     let(:another_project) { create(:project, namespace: group) }
+
     let!(:another_vulnerability) { create(:vulnerability, :with_findings, project: another_project) }
-    let(:filters) { { project_id: [another_project.id] } }
-    let(:vulnerable) { group }
+
+    let_it_be(:group) { create(:group) }
+    let_it_be(:archived_project) { create(:project, :archived, namespace: group) }
 
     before do
       project.update!(namespace: group)
     end
 
-    it 'only returns vulnerabilities matching the given projects' do
-      is_expected.to contain_exactly(another_vulnerability)
+    context 'when filtered by project' do
+      let!(:archived_vulnerability) { create(:vulnerability, :with_findings, project: archived_project) }
+      let(:filters) { { project_id: [another_project.id, archived_project.id] } }
+
+      it 'only returns vulnerabilities matching the given projects' do
+        is_expected.to contain_exactly(another_vulnerability)
+      end
+
+      context 'when including archived projects' do
+        let(:filters) { super().merge(include_archived_projects: true) }
+
+        it 'returns vulnerabilities matching the given projects' do
+          is_expected.to contain_exactly(another_vulnerability, archived_vulnerability)
+        end
+      end
     end
   end
 
diff --git a/ee/spec/finders/security/vulnerability_reads_finder_spec.rb b/ee/spec/finders/security/vulnerability_reads_finder_spec.rb
index 1b6220cb1b3f2..ed30bc6cf19be 100644
--- a/ee/spec/finders/security/vulnerability_reads_finder_spec.rb
+++ b/ee/spec/finders/security/vulnerability_reads_finder_spec.rb
@@ -52,6 +52,43 @@
 
   subject(:execute) { described_class.new(vulnerable, filters).execute }
 
+  context 'when using the include_archived_projects param' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:archive_associated_vulns) { archived_project.vulnerabilities.map(&:vulnerability_read) }
+    let(:not_archived_vulns) { project.vulnerabilities.map(&:vulnerability_read) }
+    let(:all_vulns) { archive_associated_vulns + not_archived_vulns }
+    let_it_be(:group) { create(:group) }
+
+    let_it_be(:project) do
+      create(:project, namespace: group).tap do |p|
+        create(:vulnerability, :with_finding, project: p)
+      end
+    end
+
+    let_it_be(:archived_project) do
+      create(:project, :archived, namespace: group).tap do |p|
+        create(:vulnerability, :with_finding, project: p)
+      end
+    end
+
+    where(:vulnerable_object, :include_archived_projects, :result) do
+      ref(:archived_project) | true   | ref(:archive_associated_vulns)
+      ref(:archived_project) | false  | ref(:archive_associated_vulns)
+      ref(:group)            | true   | ref(:all_vulns)
+      ref(:group)            | false  | ref(:not_archived_vulns)
+    end
+
+    with_them do
+      let(:vulnerable) { vulnerable_object }
+      let(:filters) { super().merge(include_archived_projects: include_archived_projects) }
+
+      it 'filters out vulnerabilities associated with archived projects as defined' do
+        expect(subject).to eq(result)
+      end
+    end
+  end
+
   context 'when not given a second argument' do
     subject { described_class.new(project).execute }
 
@@ -136,19 +173,34 @@
     end
   end
 
-  context 'when filtered by project' do
-    let(:group) { create(:group) }
+  context 'when vulnerable is a Group' do
     let(:another_project) { create(:project, namespace: group) }
-    let!(:another_vulnerability) { create(:vulnerability, :with_finding, project: another_project) }
     let(:vulnerable) { group }
-    let(:filters) { { project_id: [another_project.id] } }
+
+    let!(:another_vulnerability) { create(:vulnerability, :with_finding, project: another_project) }
+
+    let_it_be(:group) { create(:group) }
+    let_it_be(:archived_project) { create(:project, :archived, namespace: group) }
 
     before do
       project.update!(namespace: group)
     end
 
-    it 'only returns vulnerabilities matching the given projects' do
-      is_expected.to contain_exactly(another_vulnerability.vulnerability_read)
+    context 'when filtered by project' do
+      let!(:archived_vulnerability) { create(:vulnerability, :with_findings, project: archived_project) }
+      let(:filters) { { project_id: [another_project.id, archived_project.id] } }
+
+      it 'only returns vulnerabilities matching the given projects' do
+        is_expected.to contain_exactly(another_vulnerability.vulnerability_read)
+      end
+
+      context 'when including archived projects' do
+        let(:filters) { super().merge(include_archived_projects: true) }
+
+        it 'returns vulnerabilities matching the given projects' do
+          is_expected.to contain_exactly(another_vulnerability.vulnerability_read, archived_vulnerability.vulnerability_read)
+        end
+      end
     end
   end
 
diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb
index 21149b6861f63..6b81f8c37cc93 100644
--- a/ee/spec/models/ee/group_spec.rb
+++ b/ee/spec/models/ee/group_spec.rb
@@ -681,8 +681,8 @@
     let!(:archived_vulnerability) { create(:vulnerability, project: archived_project) }
     let!(:deleted_vulnerability) { create(:vulnerability, project: deleted_project) }
 
-    it 'returns vulnerabilities for all non-archived, non-deleted projects in the group and its subgroups' do
-      is_expected.to contain_exactly(group_vulnerability, subgroup_vulnerability)
+    it 'returns vulnerabilities for all non-deleted projects in the group and its subgroups' do
+      is_expected.to contain_exactly(group_vulnerability, subgroup_vulnerability, archived_vulnerability)
     end
   end
 
diff --git a/ee/spec/support/shared_examples/models/vulnerability_and_finding_shared_examples.rb b/ee/spec/support/shared_examples/models/vulnerability_and_finding_shared_examples.rb
index 34c04b21252dd..a896b7b69307b 100644
--- a/ee/spec/support/shared_examples/models/vulnerability_and_finding_shared_examples.rb
+++ b/ee/spec/support/shared_examples/models/vulnerability_and_finding_shared_examples.rb
@@ -4,39 +4,49 @@
 
 RSpec.shared_examples 'vulnerability and finding shared examples' do
   describe 'scopes' do
-    describe '.for_projects' do
-      subject { described_class.for_projects(*params) }
+    let(:result_set_transformer) { transformer_method || :itself }
+    let(:archive_associated_objects) { archived_project.vulnerabilities.map(&result_set_transformer) }
+    let(:all_excluding_archive_associations) { all_vulns_for_params - archive_associated_objects }
 
-      let(:params) { [[project.id, archived_project.id], include_archived].compact }
+    let_it_be(:vulnerability) { create(:vulnerability, :with_finding, project: project) }
+    let_it_be(:archived_project) do
+      create(:project, :archived).tap { |p| create(:vulnerability, :with_finding, project: p) }
+    end
 
-      let_it_be(:vulnerability) { create(:vulnerability, :with_finding, project: project) }
-      let_it_be(:archived_project) { create(:project, :archived) }
+    describe '.without_archived_projects' do
+      subject { described_class.without_archived_projects }
 
-      let(:result_set_transformer) { transformer_method || :itself }
-      let(:expected_all_projects) do
-        (project.vulnerabilities + archived_project.vulnerabilities).map(&result_set_transformer)
+      let(:all_vulns_for_params) { described_class.all }
+
+      it 'returns objects where the associated project is not archived' do
+        is_expected.to contain_exactly(*all_excluding_archive_associations)
       end
+    end
 
-      let(:expected_excluding_archives) { project.vulnerabilities.map(&result_set_transformer) }
+    describe '.for_projects' do
+      subject { described_class.for_projects(*params) }
 
       let(:include_archived) { nil }
-      let!(:archived_projects_vuln) { create(:vulnerability, :with_finding, project: archived_project) }
+
+      let(:params) { [[project.id, archived_project.id], include_archived].compact }
+      let(:all_vulns_for_params) do
+        (project.vulnerabilities + archived_project.vulnerabilities).map(&result_set_transformer)
+      end
 
       before do
-        # create a vulnerability associated on a project that will be
-        # filtered out
+        # vulnerability associated on a project that will be filtered out
         create(:vulnerability, :with_finding, project: create(:project))
       end
 
       it 'returns objects related to the given project IDs' do
-        is_expected.to contain_exactly(*expected_excluding_archives)
+        is_expected.to contain_exactly(*all_excluding_archive_associations)
       end
 
       context 'when including archived projects' do
         let(:include_archived) { true }
 
         it 'returns objects related to the given project IDs' do
-          is_expected.to contain_exactly(*expected_all_projects)
+          is_expected.to contain_exactly(*all_vulns_for_params)
         end
       end
 
@@ -44,7 +54,7 @@
         let(:include_archived) { false }
 
         it 'returns objects related to the given project IDs' do
-          is_expected.to contain_exactly(*expected_excluding_archives)
+          is_expected.to contain_exactly(*all_excluding_archive_associations)
         end
       end
     end
-- 
GitLab