npm_and_yarn/spec/dependabot/npm_and_yarn/update_checker/vulnerability_auditor_spec.rb
# typed: false
# frozen_string_literal: true

require "spec_helper"
require "dependabot/dependency"
require "dependabot/security_advisory"
require "dependabot/npm_and_yarn/native_helpers"
require "dependabot/npm_and_yarn/update_checker/vulnerability_auditor"

RSpec.describe Dependabot::NpmAndYarn::UpdateChecker::VulnerabilityAuditor do
  subject(:vulnerability_auditor) { described_class.new(dependency_files: dependency_files, credentials: credentials) }

  let(:dependency_files) { [] } # Specified in scenarios below.
  let(:credentials) do
    [Dependabot::Credential.new(
      {
        "type" => "git_source",
        "host" => "github.com",
        "username" => "x-access-token",
        "password" => "token"
      }
    )]
  end

  before do
    allow(Dependabot.logger).to receive(:info)
  end

  describe "#audit" do
    let(:dependency) do
      Dependabot::Dependency.new(
        name: "@dependabot-fixtures/npm-transitive-dependency",
        version: "1.0.0",
        requirements: [{
          file: "package-lock.json",
          requirement: "1.0.0",
          groups: ["dependencies"],
          source: nil
        }],
        package_manager: "npm_and_yarn"
      )
    end

    context "when logging is present" do
      let(:dependency_files) { project_dependency_files("npm8/simple") }

      it "logs audit start" do
        expect(Dependabot.logger).to receive(:info).with(/starting audit/i)
        vulnerability_auditor.audit(dependency: dependency, security_advisories: [])
      end
    end

    context "when a fix is available" do
      let(:dependency_files) { project_dependency_files("npm8/transitive_dependency_locked_by_intermediate") }

      it "logs viable result and returns fix_available => true" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        expect(Dependabot.logger).to receive(:info).with(/audit result viable/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "dependency_name" => dependency.name,
            "current_version" => "1.0.0",
            "target_version" => "1.0.1",
            "fix_available" => true,
            "fix_updates" => [{
              "dependency_name" => "@dependabot-fixtures/npm-intermediate-dependency",
              "current_version" => "0.0.1",
              "target_version" => "0.0.2",
              "top_level_ancestors" => ["@dependabot-fixtures/npm-parent-dependency"]
            }],
            "top_level_ancestors" => ["@dependabot-fixtures/npm-parent-dependency"]
          )
      end
    end

    context "when a fix is available but a fixed version is also a dependency" do
      let(:dependency_files) { project_dependency_files("npm8/locked_transitive_dependency_plus_fixed") }

      it "logs viable result and returns fix_available => true" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        expect(Dependabot.logger).to receive(:info).with(/audit result viable/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "dependency_name" => dependency.name,
            "current_version" => "1.0.0",
            "target_version" => "1.0.1",
            "fix_available" => true,
            "fix_updates" => [{
              "dependency_name" => "@dependabot-fixtures/npm-parent-dependency",
              "current_version" => "2.0.0",
              "target_version" => "2.0.2",
              "top_level_ancestors" => []
            }],
            "top_level_ancestors" => ["@dependabot-fixtures/npm-parent-dependency"]
          )
      end
    end

    context "when a fix removes the vulnerable dependency" do
      let(:dependency_files) { project_dependency_files("npm8/locked_transitive_dependency_removed") }

      it "omits target_version to indicate removal" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "dependency_name" => dependency.name,
            "current_version" => "1.0.0",
            "fix_available" => true,
            "fix_updates" => [{
              "dependency_name" => "@dependabot-fixtures/npm-remove-dependency",
              "current_version" => "10.0.0",
              "target_version" => "10.0.1",
              "top_level_ancestors" => []
            }],
            "top_level_ancestors" => ["@dependabot-fixtures/npm-remove-dependency"]
          )
      end
    end

    context "when a fix doesn't resolve the vulnerability" do
      let(:dependency_files) { project_dependency_files("npm8/locked_transitive_dependency") }

      it "logs dependency_still_vulnerable and returns fix_available => false" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        allow(Dependabot::SharedHelpers)
          .to receive(:run_helper_subprocess)
          .and_return({
            "fix_available" => true,
            "target_version" => "1.0.0"
          })

        expect(Dependabot.logger).to receive(:info).with(/audit result not viable: dependency_still_vulnerable/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "fix_available" => false,
            "explanation" => "No patched version available for #{dependency.name}"
          )
      end
    end

    context "when a fix would downgrade a dependency" do
      let(:dependency_files) { project_dependency_files("npm8/locked_transitive_dependency") }

      it "logs downgrades_dependencies and returns fix_available => false" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        allow(Dependabot::SharedHelpers)
          .to receive(:run_helper_subprocess)
          .and_return({
            "dependency_name" => dependency.name,
            "fix_available" => true,
            "current_version" => "1.0.0",
            "target_version" => "1.0.1",
            "fix_updates" => [{
              "dependency_name" => "@dependabot-fixtures/npm-parent-dependency",
              "current_version" => "2.0.0",
              "target_version" => "1.0.2",
              "top_level_ancestors" => []
            }],
            "top_level_ancestors" => ["@dependabot-fixtures/npm-parent-dependency"]
          })

        expect(Dependabot.logger).to receive(:info).with(/audit result not viable: downgrades_dependencies/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "fix_available" => false,
            "explanation" => "No patched version available for #{dependency.name}"
          )
      end
    end

    context "when the vulnerability only exists in an out of date lockfile" do
      let(:dependency_files) { project_dependency_files("npm8/locked_transitive_dependency_outdated") }

      it "logs fix_incomplete and returns fix_available => false" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        expect(Dependabot.logger).to receive(:info).with(/audit result not viable: fix_incomplete/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "fix_available" => false,
            "explanation" => "The lockfile might be out of sync?"
          )
      end
    end

    context "when the project has no lockfile" do
      let(:dependency_files) { project_dependency_files("npm6/no_lockfile") }

      it "logs missing lockfile and returns fix_available => false" do
        expect(Dependabot.logger).to receive(:info).with(/missing lockfile/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: []))
          .to include("fix_available" => false)
      end
    end

    context "when the native helper raises" do
      let(:dependency_files) { project_dependency_files("npm8/simple") }

      it "logs the failure and returns fix_available => false" do
        # Stub native helper path with the `false` builtin in order to get a
        # non-zero exit status from the helper subprocess and cause
        # `Dependabot::SharedHelpers::HelperSubprocessFailed` to be raised.
        allow(Dependabot::NpmAndYarn::NativeHelpers)
          .to receive(:helper_path).and_return("false")

        expect(Dependabot.logger).to receive(:info).with(/failed/i)
        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: []))
          .to include("fix_available" => false)
      end
    end

    context "when the audit report contains a vuln effects cycle" do
      let(:dependency_files) { project_dependency_files("npm8/transitive_dependency_effects_cycle") }

      it "returns a hash with the target version and transitive updates to make" do
        security_advisories = [
          Dependabot::SecurityAdvisory.new(
            dependency_name: dependency.name,
            package_manager: "npm_and_yarn",
            vulnerable_versions: ["<1.0.1"],
            safe_versions: ["1.0.1"]
          )
        ]

        expect(vulnerability_auditor.audit(dependency: dependency, security_advisories: security_advisories))
          .to include(
            "dependency_name" => dependency.name,
            "current_version" => "1.0.0",
            "target_version" => "1.0.1",
            "fix_available" => true,
            "fix_updates" => [{
              "dependency_name" => "@dependabot-fixtures/npm-parent-dependency-4",
              "current_version" => "1.0.0",
              "target_version" => "2.0.0",
              "top_level_ancestors" => []
            }],
            "top_level_ancestors" => ["@dependabot-fixtures/npm-parent-dependency-4"]
          )
      end
    end
  end
end
