# frozen_string_literal: true

require 'spec_helper'

RSpec.describe VulnerabilityFeedback::CreateService, '#execute' do
  let(:group)   { create(:group) }
  let(:project) { create(:project, :public, :repository, namespace: group) }
  let(:user)    { create(:user) }
  let(:pipeline) { create(:ci_pipeline, project: project, created_at: 6.minutes.ago, updated_at: 6.minutes.ago) }
  let(:dismiss_vulnerability) { true }

  before do
    group.add_developer(user)
  end

  context 'when params are valid' do
    let(:category) { 'sast' }
    let(:project_fingerprint) { '418291a26024a1445b23fe64de9380cdcdfd1fa8' }
    let(:feedback_params) do
      {
        feedback_type: 'dismissal', pipeline_id: pipeline.id, category: category,
        project_fingerprint: project_fingerprint,
        comment: 'a dismissal comment',
        dismiss_vulnerability: dismiss_vulnerability,
        vulnerability_data: {
          blob_path: '/path/to/blob',
          category: category,
          priority: 'Low', line: '41',
          file: 'subdir/src/main/java/com/gitlab/security_products/tests/App.java',
          cve: '818bf5dacb291e15d9e6dc3c5ac32178:PREDICTABLE_RANDOM',
          name: 'Predictable pseudorandom number generator',
          description: 'Description of Predictable pseudorandom number generator',
          tool: 'find_sec_bugs'
        }
      }
    end

    context 'when user is not authorized' do
      let(:unauthorized_user) { create(:user) }

      it 'raise error if permission is denied' do
        expect { described_class.new(project, unauthorized_user, feedback_params).execute }
          .to raise_error(Gitlab::Access::AccessDeniedError)
      end
    end

    context 'when feedback_type is dismissal' do
      let(:result) { described_class.new(project, user, feedback_params).execute }
      let!(:finding) do
        create(:vulnerabilities_finding,
               :detected,
               project: project,
               report_type: category,
               project_fingerprint: project_fingerprint)
      end

      it 'creates the feedback with the given params' do
        expect(result[:status]).to eq(:success)
        feedback = result[:vulnerability_feedback]
        expect(feedback).to be_persisted
        expect(feedback.project).to eq(project)
        expect(feedback.author).to eq(user)
        expect(feedback.feedback_type).to eq('dismissal')
        expect(feedback.pipeline_id).to eq(pipeline.id)
        expect(feedback.category).to eq(category)
        expect(feedback.project_fingerprint).to eq(project_fingerprint)
        expect(feedback.for_dismissal?).to eq(true)
        expect(feedback.for_issue?).to eq(false)
        expect(feedback.issue).to be_nil
        expect(feedback.for_merge_request?).to eq(false)
        expect(feedback.merge_request).to be_nil
      end

      context 'when pipeline was updated more than 5 minutes ago' do
        it 'touches pipeline related to feedback' do
          expect { result }.to change { pipeline.reload.updated_at }
        end
      end

      context 'when pipeline was updated less than 5 minutes ago' do
        let(:pipeline) { create(:ci_pipeline, project: project, created_at: 4.minutes.ago, updated_at: 4.minutes.ago) }

        it 'does not touch pipeline related to feedback' do
          expect { result }.not_to change { pipeline.reload.updated_at }
        end
      end

      context 'when feedback params has a comment' do
        it 'sets the comment attributes' do
          feedback = result[:vulnerability_feedback]

          expect(feedback.comment).to eq('a dismissal comment')
          expect(feedback.comment_author).to eq(user)
          expect(feedback.comment_timestamp).not_to be_nil
        end
      end

      context 'when feedback params does not have a comment' do
        before do
          feedback_params[:comment] = nil
        end

        it 'does not set comment attributes' do
          feedback = result[:vulnerability_feedback]

          expect(feedback.comment).to be_nil
          expect(feedback.comment_author).to be_nil
          expect(feedback.comment_timestamp).to be_nil
        end
      end

      context 'when the `dismiss_vulnerability` argument is true' do
        context 'when the security_dashboard is not enabled' do
          it 'does not dismiss the existing vulnerability' do
            expect { result }.not_to change { finding.vulnerability.reload.state }.from('detected')
          end
        end

        context 'when the security_dashboard is enabled' do
          before do
            stub_licensed_features(security_dashboard: true)
          end

          it 'dismisses the existing vulnerability' do
            expect { result }.to change { finding.vulnerability.reload.state }.from('detected').to('dismissed')
          end
        end
      end

      context 'when the `dismiss_vulnerability` argument is false' do
        let(:dismiss_vulnerability) { false }

        context 'when the security_dashboard is not enabled' do
          it 'does not dismiss the existing vulnerability' do
            expect { result }.not_to change { finding.vulnerability.reload.state }.from('detected')
          end
        end

        context 'when the security_dashboard is enabled' do
          before do
            stub_licensed_features(security_dashboard: true)
          end

          it 'dismisses the existing vulnerability' do
            expect { result }.not_to change { finding.vulnerability.reload.state }.from('detected')
          end
        end
      end
    end

    context 'when feedback_type is issue' do
      let(:result) do
        described_class.new(
          project,
          user,
          feedback_params.merge(feedback_type: 'issue')
        ).execute
      end

      it 'creates the feedback with the given params' do
        expect(result[:status]).to eq(:success)
        feedback = result[:vulnerability_feedback]
        expect(feedback).to be_persisted
        expect(feedback.project).to eq(project)
        expect(feedback.author).to eq(user)
        expect(feedback.feedback_type).to eq('issue')
        expect(feedback.pipeline_id).to eq(pipeline.id)
        expect(feedback.category).to eq('sast')
        expect(feedback.project_fingerprint).to eq('418291a26024a1445b23fe64de9380cdcdfd1fa8')
        expect(feedback.for_dismissal?).to eq(false)
        expect(feedback.for_issue?).to eq(true)
        expect(feedback.issue).to be_an(Issue)
        expect(feedback.for_merge_request?).to eq(false)
        expect(feedback.merge_request).to be_nil
        expect(feedback.finding_uuid).to be_nil
      end

      it 'updates the feedback when it already exists' do
        result

        expect { described_class.new(project, user, feedback_params.merge(feedback_type: 'issue')).execute }.not_to change(Vulnerabilities::Feedback, :count)
      end

      it 'creates a new issue when feedback already exists and issue has been deleted' do
        result

        expect { result[:vulnerability_feedback].issue.destroy}.to change(Issue, :count).by(-1)
        expect { described_class.new(project, user, feedback_params.merge(feedback_type: 'issue')).execute }.to change(Issue, :count).by(1)
      end

      it 'delegates the Issue creation to CreateFromVulnerabilityDataService' do
        expect_next_instance_of(Issues::CreateFromVulnerabilityDataService) do |instance|
          expect(instance).to receive(:execute).once.and_call_original
        end

        expect(result[:status]).to eq(:success)
      end

      context 'when the id of the vulnerability is provided in vulnerability_data params' do
        before do
          stub_licensed_features(security_dashboard: true)
        end

        let(:feedback_params_with_vulnerability_id) do
          feedback_params.deep_merge(
            feedback_type: 'issue',
            vulnerability_data: { vulnerability_id: vulnerability_id }
          ).except(:dismiss_vulnerability)
        end

        subject(:result) { described_class.new(project, user, feedback_params_with_vulnerability_id).execute }

        context 'when id is missing' do
          let(:vulnerability_id) { nil }

          it 'does not create new Vulnerabilities::IssueLink' do
            expect { subject }.not_to change { Vulnerabilities::IssueLink.count }
          end

          it 'creates the feedback' do
            expect(result[:status]).to eq(:success)
            expect(result[:vulnerability_feedback]).to be_persisted
          end
        end

        context 'when id is invalid' do
          let(:vulnerability_id) { 9999 }

          it 'raises Gitlab::Access::AccessDeniedError' do
            expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
          end
        end

        context 'when id belongs to other project' do
          let(:vulnerability) { create(:vulnerability, :with_findings) }
          let(:vulnerability_id) { vulnerability.id }

          it 'raises Gitlab::Access::AccessDeniedError' do
            expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
          end
        end

        context 'when id is valid' do
          let(:vulnerability) { create(:vulnerability, :with_findings, project: project) }
          let(:vulnerability_id) { vulnerability.id }
          let(:last_issue_link) { Vulnerabilities::IssueLink.last }
          let(:last_vulnerability_feedback) { Vulnerabilities::Feedback.last }

          it 'delegates issue link creation to VulnerabilityIssueLinks::CreateService' do
            expect(VulnerabilityIssueLinks::CreateService).to(receive(:new)
              .with(user, vulnerability, anything, link_type: Vulnerabilities::IssueLink.link_types[:created])
              .once
              .and_call_original)

            expect(result[:status]).to eq(:success)
          end

          it 'delegates work to VulnerabilityIssueLinks::CreateService' do
            expect_next_instance_of(VulnerabilityIssueLinks::CreateService) do |instance|
              expect(instance).to receive(:execute).with(no_args).once.and_call_original
            end

            expect(result[:status]).to eq(:success)
          end

          it 'issue link has correctly set vulnerability and link type' do
            subject

            expect(last_issue_link).to be_created
            expect(last_issue_link.vulnerability).to eq(vulnerability)
            expect(last_issue_link.issue).to eq(last_vulnerability_feedback.issue)
          end

          it 'creates the feedback' do
            expect(result[:status]).to eq(:success)
            expect(result[:vulnerability_feedback]).to be_persisted
          end

          context 'when issue link is already created' do
            context 'when feedback does not exist' do
              let!(:issue_link) { create(:vulnerabilities_issue_link, :created, vulnerability: vulnerability) }

              it 'does not create new issue link' do
                expect { subject }.not_to change { Vulnerabilities::IssueLink.count }
              end

              it 'does not create new issue' do
                expect { subject }.not_to change { Issue.count }
              end

              it 'does not create a feedback' do
                expect(result[:status]).to eq(:error)
              end
            end

            context 'when feedback already exists' do
              let!(:vulnerability_feedback) { create(:vulnerability_feedback, :issue, :comment, project: project, author: user, **feedback_params_with_vulnerability_id) }
              let!(:issue_link) { create(:vulnerabilities_issue_link, :created, vulnerability: vulnerability, issue: vulnerability_feedback.issue) }

              it 'does not create new issue link' do
                expect { subject }.not_to change { Vulnerabilities::IssueLink.count }
              end

              it 'does not create new issue' do
                expect { subject }.not_to change { Issue.count }
              end

              it 'returns the feedback' do
                expect(result[:status]).to eq(:success)
                expect(result[:vulnerability_feedback]).to be_persisted
              end
            end
          end
        end
      end

      context 'when a previously created issue is provided' do
        let(:issue) { create(:issue, project: project) }

        before do
          feedback_params.merge!({ issue: issue })
        end

        it 'does not create a new issue' do
          expect { result }.not_to change { Issue.count }
        end

        it 'sets the feedback issue to the created issue' do
          feedback = result[:vulnerability_feedback]

          expect(feedback.issue).to eq(issue)
        end
      end
    end

    context 'when feedback_type is merge_request' do
      let(:remediations_folder) { Rails.root.join('ee/spec/fixtures/security_reports/remediations') }
      let(:yarn_lock_content) do
        File.read(
          File.join(remediations_folder, "yarn.lock")
        )
      end

      let(:project) do
        create(:project, :custom_repo, namespace: group, files: { 'yarn.lock' => yarn_lock_content })
      end

      let(:remediation_diff) do
        Base64.encode64(
          File.read(
            File.join(remediations_folder, "remediation.patch")
          )
        )
      end

      let(:result) do
        params = feedback_params.merge(
          feedback_type: 'merge_request',
          category: 'dependency_scanning'
        )
        params[:vulnerability_data][:category] = 'dependency_scanning'
        params[:vulnerability_data][:remediations] = [
          { diff: remediation_diff }
        ]

        described_class.new(
          project,
          user,
          params
        ).execute
      end

      it 'creates the feedback with the given params' do
        expect(result[:status]).to eq(:success)
        feedback = result[:vulnerability_feedback]
        expect(feedback).to be_persisted
        expect(feedback.project).to eq(project)
        expect(feedback.author).to eq(user)
        expect(feedback.feedback_type).to eq('merge_request')
        expect(feedback.pipeline_id).to eq(pipeline.id)
        expect(feedback.category).to eq('dependency_scanning')
        expect(feedback.project_fingerprint).to eq('418291a26024a1445b23fe64de9380cdcdfd1fa8')
        expect(feedback.for_dismissal?).to eq(false)
        expect(feedback.for_issue?).to eq(false)
        expect(feedback.issue).to be_nil
        expect(feedback.for_merge_request?).to eq(true)
        expect(feedback.merge_request).to be_an(MergeRequest)
      end

      it 'delegates the MergeRequest creation to CreateFromVulnerabilityDataService' do
        expect_next_instance_of(MergeRequests::CreateFromVulnerabilityDataService) do |service|
          expect(service).to receive(:execute).once.and_call_original
        end

        expect(result[:status]).to eq(:success)
      end

      it 'destroys merge_request and branch if feedback fails to persist' do
        expect_next_instance_of(Vulnerabilities::Feedback) do |feedback|
          expect(feedback).to receive(:save).and_return(false)
        end

        expect(result[:status]).to eq(:error)
        expect(Vulnerabilities::Feedback.count).to eq 0
        expect(MergeRequest.count).to eq 0

        branches = BranchesFinder.new(project.repository, {}).execute
        expect(branches.length).to eq 1
      end
    end

    context 'when finding_uuid is provided' do
      let(:vulnerability) { create(:vulnerability, :with_findings, project: project) }
      let(:result) do
        described_class.new(
          project,
          user,
          feedback_params.merge(finding_uuid: vulnerability.finding.uuid)
        ).execute
      end

      it 'sets the finding_uuid' do
        expect(result[:vulnerability_feedback].finding_uuid).to eq(vulnerability.finding.uuid)
      end
    end
  end

  context 'when feedback exists' do
    let!(:feedback) { create(:vulnerability_feedback, project: project) }
    let(:another_pipeline) { create(:ci_pipeline) }
    let(:feedback_params) do
      {
        feedback_type: feedback.feedback_type, pipeline_id: another_pipeline.id, category: feedback.category,
        project_fingerprint: feedback.project_fingerprint,
        comment: feedback.comment,
        vulnerability_data: feedback.vulnerability_data
      }
    end

    it 'returns error when params are invalid' do
      result = described_class.new(project, user, feedback_params).execute

      expect(result[:status]).to eq(:error)
      expect(result[:message][:pipeline]).to eq(["must associate the same project"])
    end
  end

  context 'when params are invalid' do
    context 'when vulnerability_data params is missing and feedback_type is issue' do
      let(:feedback_params) do
        {
          feedback_type: 'issue', pipeline_id: pipeline.id, category: 'sast',
          project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8'
        }
      end

      let(:result) { described_class.new(project, user, feedback_params).execute }

      it 'returns error with correct message' do
        expect(result[:status]).to eq(:error)
        expect(result[:message][:vulnerability_data]).to eq(["can't be blank"])
      end
    end

    context 'when feedback_type is invalid' do
      let(:feedback_params) do
        {
          feedback_type: 'foo', pipeline_id: pipeline.id, category: 'sast',
          project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8'
        }
      end

      let(:result) { described_class.new(project, user, feedback_params).execute }

      it 'returns error with correct message' do
        expect(result[:status]).to eq(:error)
        expect(result[:message]).to eq("'foo' is not a valid feedback_type")
      end
    end

    context 'when category is invalid' do
      let(:feedback_params) do
        {
          feedback_type: 'dismissal', pipeline_id: pipeline.id, category: 'foo',
          project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8'
        }
      end

      let(:result) { described_class.new(project, user, feedback_params).execute }

      it 'returns error with correct message' do
        expect(result[:status]).to eq(:error)
        expect(result[:message]).to eq("'foo' is not a valid category")
      end
    end
  end
end
