# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Vulnerability do
  let(:state_values) { { detected: 1, dismissed: 2, resolved: 3, confirmed: 4 } }
  let(:severity_values) { { info: 1, unknown: 2, low: 4, medium: 5, high: 6, critical: 7 } }

  let(:confidence_values) do
    { ignore: 1, unknown: 2, experimental: 3, low: 4, medium: 5, high: 6, confirmed: 7 }
  end

  let(:report_types) do
    { sast: 0,
      dependency_scanning: 1,
      container_scanning: 2,
      dast: 3,
      secret_detection: 4,
      coverage_fuzzing: 5,
      api_fuzzing: 6 }
  end

  it { is_expected.to define_enum_for(:state).with_values(state_values) }
  it { is_expected.to define_enum_for(:severity).with_values(severity_values).with_prefix(:severity) }
  it { is_expected.to define_enum_for(:confidence).with_values(confidence_values).with_prefix(:confidence) }
  it { is_expected.to define_enum_for(:report_type).with_values(report_types) }

  it_behaves_like 'having unique enum values'

  describe 'associations' do
    subject { build(:vulnerability) }

    it { is_expected.to belong_to(:project) }
    it { is_expected.to belong_to(:milestone) }
    it { is_expected.to belong_to(:epic) }
    it { is_expected.to have_many(:findings).class_name('Vulnerabilities::Finding').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:dismissed_findings).class_name('Vulnerabilities::Finding').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:issue_links).class_name('Vulnerabilities::IssueLink').inverse_of(:vulnerability) }
    it { is_expected.to have_many(:created_issue_links).class_name('Vulnerabilities::IssueLink').inverse_of(:vulnerability).conditions(link_type: Vulnerabilities::IssueLink.link_types['created']) }
    it { is_expected.to have_many(:related_issues).through(:issue_links).source(:issue) }
    it { is_expected.to belong_to(:author).class_name('User') }
    it { is_expected.to belong_to(:updated_by).class_name('User') }
    it { is_expected.to belong_to(:last_edited_by).class_name('User') }
    it { is_expected.to belong_to(:resolved_by).class_name('User') }
    it { is_expected.to belong_to(:dismissed_by).class_name('User') }
    it { is_expected.to belong_to(:confirmed_by).class_name('User') }

    it { is_expected.to have_one(:group).through(:project) }

    it { is_expected.to have_many(:findings).class_name('Vulnerabilities::Finding').dependent(false) }
    it { is_expected.to have_many(:notes).dependent(:delete_all) }
    it { is_expected.to have_many(:user_mentions).class_name('VulnerabilityUserMention') }
  end

  describe 'validations' do
    subject { build(:vulnerability) }

    it { is_expected.to validate_presence_of(:project) }
    it { is_expected.to validate_presence_of(:author) }
    it { is_expected.to validate_presence_of(:title) }
    it { is_expected.to validate_presence_of(:severity) }
    it { is_expected.to validate_presence_of(:confidence) }
    it { is_expected.to validate_presence_of(:report_type) }

    it { is_expected.to validate_length_of(:title).is_at_most(::Issuable::TITLE_LENGTH_MAX) }
    it { is_expected.to validate_length_of(:title_html).is_at_most(::Issuable::TITLE_HTML_LENGTH_MAX) }
    it { is_expected.to validate_length_of(:description).is_at_most(::Issuable::DESCRIPTION_LENGTH_MAX).allow_nil }
    it { is_expected.to validate_length_of(:description_html).is_at_most(::Issuable::DESCRIPTION_HTML_LENGTH_MAX).allow_nil }
  end

  describe 'text fields' do
    subject { create(:vulnerability, title: '_My title_ ', description: '**Hello `world`**') }

    it 'has proper markdown for title field' do
      expect(subject.title_html).to eq('_My title_') # no markdown rendering because it's a single line field
    end

    it 'has proper markdown for title field' do
      expect(subject.description_html).to(
        eq('<p data-sourcepos="1:1-1:17" dir="auto"><strong>Hello <code>world</code></strong></p>')
      )
    end

    context 'redactable fields' do
      before do
        stub_commonmark_sourcepos_disabled
      end

      it_behaves_like 'model with redactable field' do
        let(:model) { create(:vulnerability) }
        let(:field) { :description }
      end
    end
  end

  describe '.visible_to_user_and_access_level' do
    let(:project1) { create(:project) }
    let(:project2) { create(:project) }
    let!(:vulnerability1) { create(:vulnerability, project: project1) }
    let!(:vulnerability2) { create(:vulnerability, project: project2) }

    let(:user) { create(:user) }

    before do
      project1.add_developer(user)
    end

    subject { described_class.visible_to_user_and_access_level(user, ::Gitlab::Access::DEVELOPER) }

    it 'returns vulnerabilities visible for given user with provided access level' do
      is_expected.to contain_exactly(vulnerability1)
    end
  end

  describe '.with_limit' do
    let(:project) { create(:project) }
    let!(:vulnerabilities) { create_list(:vulnerability, 10, project: project) }

    subject { described_class.with_limit(5) }

    it 'returns vulnerabilities limited by provided value' do
      expect(subject.count).to eq(5)
    end
  end

  describe '.autocomplete_search' do
    using RSpec::Parameterized::TableSyntax

    let(:project) { create(:project) }
    let!(:vulnerability_1) { create(:vulnerability, title: 'Predictable pseudorandom number generator') }
    let!(:vulnerability_2) { create(:vulnerability, title: 'Use of pseudorandom MD2, MD4, or MD5 hash function.') }

    subject { described_class.autocomplete_search(search) }

    where(:search, :filtered_vulnerabilities) do
      'PSEUDORANDOM'             | [:vulnerability_1, :vulnerability_2]
      'Predictable PSEUDORANDOM' | [:vulnerability_1]
      'mD2'                      | [:vulnerability_2]
    end

    with_them do
      it 'returns the vulnerabilities filtered' do
        expect(subject).to match_array(filtered_vulnerabilities.map { |name| public_send(name) })
      end
    end

    context 'when id is used in search params' do
      let(:search) { vulnerability_1.id.to_s }

      it { is_expected.to match_array([vulnerability_1]) }
    end

    context 'when query is empty' do
      let(:search) { '' }

      it { is_expected.to match_array([vulnerability_1, vulnerability_2]) }
    end
  end

  describe '.for_projects' do
    let(:project1) { create(:project) }
    let(:project2) { create(:project) }
    let!(:vulnerability1) { create(:vulnerability, project: project1) }
    let!(:vulnerability2) { create(:vulnerability, project: project2) }

    subject { described_class.for_projects([project1.id]) }

    it 'returns vulnerabilities related to the given project IDs' do
      is_expected.to contain_exactly(vulnerability1)
    end
  end

  describe '.with_report_types' do
    let!(:sast_vulnerability) { create(:vulnerability, :sast) }
    let!(:dast_vulnerability) { create(:vulnerability, :dast) }
    let!(:dependency_scanning_vulnerability) { create(:vulnerability, :dependency_scanning) }
    let(:report_types) { %w[sast dast] }

    subject { described_class.with_report_types(report_types) }

    it 'returns vulnerabilities matching the given report_types' do
      is_expected.to contain_exactly(sast_vulnerability, dast_vulnerability)
    end
  end

  describe '.with_severities' do
    let!(:high_vulnerability) { create(:vulnerability, :high) }
    let!(:medium_vulnerability) { create(:vulnerability, :medium) }
    let!(:low_vulnerability) { create(:vulnerability, :low) }
    let(:severities) { %w[medium low] }

    subject { described_class.with_severities(severities) }

    it 'returns vulnerabilities matching the given severities' do
      is_expected.to contain_exactly(medium_vulnerability, low_vulnerability)
    end
  end

  describe '.with_states' do
    let!(:detected_vulnerability) { create(:vulnerability, :detected) }
    let!(:dismissed_vulnerability) { create(:vulnerability, :dismissed) }
    let!(:confirmed_vulnerability) { create(:vulnerability, :confirmed) }
    let(:states) { %w[detected confirmed] }

    subject { described_class.with_states(states) }

    it 'returns vulnerabilities matching the given states' do
      is_expected.to contain_exactly(detected_vulnerability, confirmed_vulnerability)
    end
  end

  describe '.with_scanners' do
    let!(:detected_vulnerability) { create(:vulnerability, :detected, :with_findings) }
    let!(:dismissed_vulnerability) { create(:vulnerability, :dismissed, :with_findings) }
    let!(:confirmed_vulnerability) { create(:vulnerability, :confirmed, :with_findings) }
    let(:scanners) { [detected_vulnerability.finding_scanner_external_id, confirmed_vulnerability.finding_scanner_external_id] }

    subject { described_class.with_scanners(scanners) }

    it 'returns vulnerabilities matching the given scanners' do
      is_expected.to contain_exactly(detected_vulnerability, confirmed_vulnerability)
    end
  end

  describe '.order_severity_asc' do
    subject { described_class.order_severity_asc }

    it 'returns vulnerabilities ordered by severity' do
      low_vulnerability = create(:vulnerability, :low)
      critical_vulnerability = create(:vulnerability, :critical)
      medium_vulnerability = create(:vulnerability, :medium)

      is_expected.to eq([low_vulnerability, medium_vulnerability, critical_vulnerability])
    end
  end

  describe '.order_severity_desc' do
    subject { described_class.order_severity_desc }

    it 'returns vulnerabilities ordered by severity' do
      low_vulnerability = create(:vulnerability, :low)
      critical_vulnerability = create(:vulnerability, :critical)
      medium_vulnerability = create(:vulnerability, :medium)

      is_expected.to eq([critical_vulnerability, medium_vulnerability, low_vulnerability])
    end
  end

  describe '.order_title_' do
    let_it_be(:vulnerability_b) { create(:vulnerability, title: 'B Vulnerability') }
    let_it_be(:vulnerability_a) { create(:vulnerability, title: 'A Vulnerability') }
    let_it_be(:vulnerability_c) { create(:vulnerability, title: 'C Vulnerability') }

    describe 'asc' do
      subject { described_class.order_title_asc }

      it 'returns vulnerabilities ordered by title' do
        is_expected.to eq([vulnerability_a, vulnerability_b, vulnerability_c])
      end
    end

    describe 'desc' do
      subject { described_class.order_title_desc }

      it 'returns vulnerabilities ordered by title' do
        is_expected.to eq([vulnerability_c, vulnerability_b, vulnerability_a])
      end
    end
  end

  describe '.order_created_at_' do
    let_it_be(:old_vulnerability) { create(:vulnerability, created_at: 2.weeks.ago) }
    let_it_be(:very_old_vulnerability) { create(:vulnerability, created_at: 1.year.ago) }
    let_it_be(:fresh_vulnerability) { create(:vulnerability, created_at: 3.days.ago) }

    describe 'desc' do
      subject { described_class.order_created_at_asc }

      it 'returns vulnerabilities ordered by created_at' do
        is_expected.to eq([very_old_vulnerability, old_vulnerability, fresh_vulnerability])
      end
    end

    describe 'asc' do
      subject { described_class.order_created_at_desc }

      it 'returns vulnerabilities ordered by created_at' do
        is_expected.to eq([fresh_vulnerability, old_vulnerability, very_old_vulnerability])
      end
    end
  end

  describe '.order_id_desc' do
    subject { described_class.order_id_desc }

    before do
      create_list(:vulnerability, 10)
    end

    it 'returns vulnerabilities ordered by id' do
      is_expected.to be_sorted(:id, :desc)
    end
  end

  describe '.order_report_type_' do
    let_it_be(:vulnerability_dast) { create(:vulnerability, :dast) }
    let_it_be(:vulnerability_secret_detection) { create(:vulnerability, :secret_detection) }
    let_it_be(:vulnerability_sast) { create(:vulnerability, :sast) }
    let_it_be(:vulnerability_coverage_fuzzing) { create(:vulnerability, :coverage_fuzzing) }
    let_it_be(:vulnerability_api_fuzzing) { create(:vulnerability, :api_fuzzing) }

    describe 'asc' do
      subject { described_class.order_report_type_asc }

      it 'returns vulnerabilities ordered by report_type' do
        is_expected.to eq([vulnerability_api_fuzzing, vulnerability_coverage_fuzzing, vulnerability_dast, vulnerability_sast, vulnerability_secret_detection])
      end
    end

    describe 'desc' do
      subject { described_class.order_report_type_desc }

      it 'returns vulnerabilities ordered by report_type' do
        is_expected.to eq([vulnerability_secret_detection, vulnerability_sast, vulnerability_dast, vulnerability_coverage_fuzzing, vulnerability_api_fuzzing])
      end
    end
  end

  describe '.order_state_' do
    let_it_be(:vulnerability_confirmed) { create(:vulnerability, :confirmed) }
    let_it_be(:vulnerability_detected) { create(:vulnerability, :detected) }
    let_it_be(:vulnerability_dismissed) { create(:vulnerability, :dismissed) }
    let_it_be(:vulnerability_resolved) { create(:vulnerability, :resolved) }

    describe 'asc' do
      subject { described_class.order_state_asc }

      it 'returns vulnerabilities ordered by state' do
        is_expected.to eq([vulnerability_detected, vulnerability_confirmed, vulnerability_resolved, vulnerability_dismissed])
      end
    end

    describe 'desc' do
      subject { described_class.order_state_desc }

      it 'returns vulnerabilities ordered by state' do
        is_expected.to eq([vulnerability_dismissed, vulnerability_resolved, vulnerability_confirmed, vulnerability_detected])
      end
    end
  end

  describe '.with_resolution' do
    let_it_be(:vulnerability_with_resolution) { create(:vulnerability, resolved_on_default_branch: true) }
    let_it_be(:vulnerability_without_resolution) { create(:vulnerability, resolved_on_default_branch: false) }

    subject { described_class.with_resolution(with_resolution) }

    context 'when no argument is provided' do
      subject { described_class.with_resolution }

      it { is_expected.to eq([vulnerability_with_resolution]) }
    end

    context 'when true argument is provided' do
      let(:with_resolution) { true }

      it { is_expected.to eq([vulnerability_with_resolution]) }
    end

    context 'when false argument is provided' do
      let(:with_resolution) { false }

      it { is_expected.to eq([vulnerability_without_resolution]) }
    end
  end

  describe '.with_issues' do
    let_it_be(:vulnerability_with_issues) { create(:vulnerability, :with_issue_links) }
    let_it_be(:vulnerability_without_issues) { create(:vulnerability) }

    subject { described_class.with_issues(with_issues) }

    context 'when no argument is provided' do
      subject { described_class.with_issues }

      it { is_expected.to eq([vulnerability_with_issues]) }
    end

    context 'when true argument is provided' do
      let(:with_issues) { true }

      it { is_expected.to eq([vulnerability_with_issues]) }
    end

    context 'when false argument is provided' do
      let(:with_issues) { false }

      it { is_expected.to eq([vulnerability_without_issues]) }
    end
  end

  describe '.order_by' do
    subject { described_class.order_by(method) }

    let!(:low_vulnerability) { create(:vulnerability, :low) }
    let!(:critical_vulnerability) { create(:vulnerability, :critical) }
    let!(:medium_vulnerability) { create(:vulnerability, :medium) }

    context 'when ordered by severity_desc' do
      let(:method) { :severity_desc }

      it { is_expected.to eq([critical_vulnerability, medium_vulnerability, low_vulnerability]) }
    end

    context 'when ordered by severity_asc' do
      let(:method) { :severity_asc }

      it { is_expected.to eq([low_vulnerability, medium_vulnerability, critical_vulnerability]) }
    end
  end

  describe '.counts_by_day_and_severity' do
    context 'when the vulnerability_history feature flag is disabled' do
      before do
        stub_feature_flags(vulnerability_history: false)
      end

      it 'returns an empty array' do
        create(:vulnerability, created_at: 1.day.ago)

        counts_by_day_and_severity = ::Vulnerability.counts_by_day_and_severity(1.day.ago, Date.current)

        expect(counts_by_day_and_severity).to be_empty
      end
    end

    context 'when the vulnerability_history feature flag is enabled' do
      before do
        stub_feature_flags(vulnerability_history: true)
      end

      it 'returns the count of unresolved, undismissed vulnerabilities for each severity for each day from the start date to the end date' do
        travel_to(Time.zone.parse('2019-10-31')) do
          create(:vulnerability, created_at: 5.days.ago, dismissed_at: Date.current, severity: :critical)
          create(:vulnerability, created_at: 5.days.ago, dismissed_at: 1.day.ago, severity: :high)
          create(:vulnerability, created_at: 4.days.ago, resolved_at: 2.days.ago, severity: :critical)

          counts_by_day_and_severity = ::Vulnerability.counts_by_day_and_severity(Date.parse('2019-10-22'), Date.parse('2019-10-28'))

          expect(counts_by_day_and_severity.order(:day, :severity).to_json).to eq([
            { 'id' => nil, 'severity' => 'high', 'day' => '2019-10-26', 'count' => 1 },
            { 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-26', 'count' => 1 },
            { 'id' => nil, 'severity' => 'high', 'day' => '2019-10-27', 'count' => 1 },
            { 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-27', 'count' => 2 },
            { 'id' => nil, 'severity' => 'high', 'day' => '2019-10-28', 'count' => 1 },
            { 'id' => nil, 'severity' => 'critical', 'day' => '2019-10-28', 'count' => 2 }
          ].to_json)
        end
      end

      context 'there are more than 10 days between the start and end dates' do
        it 'raises a TooManyDaysError' do
          expect { ::Vulnerability.counts_by_day_and_severity(10.days.ago.to_date, Date.current) }.to raise_error(
            ::Vulnerability::TooManyDaysError,
            'Cannot fetch counts for more than 10 days'
          )
        end
      end
    end
  end

  describe '.active_state_values' do
    let(:expected_values) { ::Vulnerability.states.values_at('detected', 'confirmed') }

    subject { described_class.active_state_values }

    it { is_expected.to match_array(expected_values) }
  end

  describe '.grouped_by_severity' do
    before do
      create_list(:vulnerability, 6, :critical)
      create_list(:vulnerability, 4, :high)
      create_list(:vulnerability, 2, :medium)
      create_list(:vulnerability, 5, :low)
      create_list(:vulnerability, 1, :info)
      create_list(:vulnerability, 3, :unknown)
    end

    subject { described_class.grouped_by_severity.count }

    it { is_expected.to eq('critical' => 6, 'high' => 4, 'info' => 1, 'low' => 5, 'medium' => 2, 'unknown' => 3) }
  end

  describe '#finding' do
    let_it_be(:project) { create(:project, :with_vulnerability) }
    let_it_be(:vulnerability) { project.vulnerabilities.first }
    let_it_be(:finding1) { create(:vulnerabilities_occurrence, vulnerability: vulnerability) }
    let_it_be(:finding2) { create(:vulnerabilities_occurrence, vulnerability: vulnerability) }

    subject { vulnerability.finding }

    context 'with multiple findings' do
      it { is_expected.to eq(finding1) }
    end
  end

  describe 'delegations' do
    it { is_expected.to delegate_method(:scanner_name).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:metadata).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:message).to(:finding).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:cve_value).to(:finding).allow_nil }
    it { is_expected.to delegate_method(:cwe_value).to(:finding).allow_nil }
    it { is_expected.to delegate_method(:other_identifier_values).to(:finding).allow_nil }
    it { is_expected.to delegate_method(:default_branch).to(:project).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:name).to(:project).with_prefix.allow_nil }
    it { is_expected.to delegate_method(:name).to(:group).with_prefix.allow_nil }
  end

  describe '#resource_parent' do
    let(:vulnerability) { build(:vulnerability) }

    subject(:resource_parent) { vulnerability.resource_parent }

    it { is_expected.to eq(vulnerability.project) }
  end

  describe '#discussions_rendered_on_frontend?' do
    let(:vulnerability) { build(:vulnerability) }

    subject(:discussions_rendered_on_frontend) { vulnerability.discussions_rendered_on_frontend? }

    it { is_expected.to be true }
  end

  describe '.parent_class' do
    subject(:parent_class) { ::Vulnerability.parent_class }

    it { is_expected.to eq(::Project) }
  end

  describe '.to_ability_name' do
    subject(:ability_name) { ::Vulnerability.to_ability_name }

    it { is_expected.to eq('vulnerability') }
  end

  describe '#note_etag_key' do
    let(:vulnerability) { create(:vulnerability) }

    it 'returns a correct etag key' do
      expect(vulnerability.note_etag_key).to eq(
        ::Gitlab::Routing.url_helpers.project_security_vulnerability_notes_path(vulnerability.project, vulnerability)
      )
    end
  end

  describe '#dismissed_by_id' do
    let_it_be(:user_1) { create(:user) }
    let_it_be(:user_2) { create(:user) }
    let_it_be(:project) { create(:project) }
    let_it_be(:occurrence) do
      create(
        :vulnerabilities_occurrence,
        report_type: :dependency_scanning,
        project: project
      )
    end
    let_it_be(:dismissal_feedback) do
      create(
        :vulnerability_feedback,
        :dependency_scanning,
        :dismissal,
        project: project,
        project_fingerprint: occurrence.project_fingerprint,
        author: user_1
      )
    end

    let(:vulnerability) { create(:vulnerability, findings: [occurrence]) }

    subject { vulnerability.dismissed_by_id }

    context 'when the `dismissed_by_id` exists' do
      before do
        vulnerability.update_attribute(:dismissed_by_id, user_2.id)
      end

      it { is_expected.to eq(user_2.id) }
    end

    context 'when the `dismissed_by_id` does not exist' do
      it { is_expected.to eq(user_1.id) }
    end
  end

  describe '#user_notes_count' do
    let_it_be(:vulnerability) { create(:vulnerability) }

    let(:expected_count) { 10 }
    let(:mock_service_class) { instance_double(Vulnerabilities::UserNotesCountService, count: expected_count) }

    subject(:user_notes_count) { vulnerability.user_notes_count }

    before do
      allow(Vulnerabilities::UserNotesCountService).to receive(:new).with(vulnerability).and_return(mock_service_class)
    end

    it 'delegates the call to Vulnerabilities::UserNotesCountService' do
      expect(user_notes_count).to eq(expected_count)
      expect(mock_service_class).to have_received(:count)
    end
  end

  describe '#after_note_changed' do
    let_it_be(:vulnerability) { create(:vulnerability) }

    let(:note) { instance_double(Note, system?: is_system_note?) }
    let(:mock_service_class) { instance_double(Vulnerabilities::UserNotesCountService, delete_cache: true) }

    subject(:after_note_changed) { vulnerability.after_note_changed(note) }

    before do
      allow(Vulnerabilities::UserNotesCountService).to receive(:new).with(vulnerability).and_return(mock_service_class)
    end

    context 'when the changed note is a system note' do
      let(:is_system_note?) { true }

      it 'does not send #delete_cache message to Vulnerabilities::UserNotesCountService' do
        after_note_changed

        expect(mock_service_class).not_to have_received(:delete_cache)
      end
    end

    context 'when the changed note is not a system note' do
      let(:is_system_note?) { false }

      it 'sends #delete_cache message to Vulnerabilities::UserNotesCountService' do
        after_note_changed

        expect(mock_service_class).to have_received(:delete_cache)
      end
    end
  end

  describe '#after_note_created' do
    subject { described_class.instance_method(:after_note_created).original_name }

    it { is_expected.to eq(:after_note_changed) }
  end

  describe '#after_note_destroyed' do
    subject { described_class.instance_method(:after_note_destroyed).original_name }

    it { is_expected.to eq(:after_note_changed) }
  end

  describe '#stat_diff' do
    let(:vulnerability) { build(:vulnerability) }

    subject { vulnerability.stat_diff }

    it { is_expected.to be_an_instance_of(Vulnerabilities::StatDiff) }
  end
end
