# typed: false
# frozen_string_literal: true

require "vulns/vulnerability"

RSpec.describe Homebrew::Vulns::Vulnerability do
  def vuln(data)
    described_class.new(data)
  end

  def semver_range(*events)
    { "affected" => [{ "ranges" => [{ "type" => "SEMVER", "events" => events }] }] }
  end

  describe "basic accessors" do
    it "parses id, summary, details, aliases, references" do
      v = vuln(
        "id"         => "CVE-2024-1234",
        "summary"    => "Something bad",
        "details"    => "Longer description",
        "aliases"    => ["GHSA-xxxx-yyyy-zzzz"],
        "references" => [{ "type" => "WEB", "url" => "https://example.com" }],
      )
      expect(v.id).to eq "CVE-2024-1234"
      expect(v.summary).to eq "Something bad"
      expect(v.details).to eq "Longer description"
      expect(v.aliases).to eq ["GHSA-xxxx-yyyy-zzzz"]
      expect(v.references).to eq [{ "type" => "WEB", "url" => "https://example.com" }]
    end

    it "defaults missing arrays to empty" do
      v = vuln("id" => "CVE-2024-1234")
      expect(v.aliases).to eq []
      expect(v.references).to eq []
      expect(v.affected).to eq []
    end
  end

  describe "#severity" do
    it "extracts critical from a CVSS v3.1 vector" do
      v = vuln("id"       => "CVE-2024-0001",
               "severity" => [{ "type"  => "CVSS_V3",
                                "score" => "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }])
      expect(v.severity).to eq :critical
      expect(v.severity_display).to eq "CRITICAL"
      expect(v.severity_level).to eq 4
    end

    it "extracts high from a CVSS v3.1 vector" do
      v = vuln("id"       => "CVE-2024-0002",
               "severity" => [{ "type"  => "CVSS_V3",
                                "score" => "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H" }])
      expect(v.severity).to eq :high
      expect(v.severity_level).to eq 3
    end

    it "extracts medium from a CVSS v3.1 vector" do
      v = vuln("id"       => "CVE-2024-0003",
               "severity" => [{ "type"  => "CVSS_V3",
                                "score" => "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N" }])
      expect(v.severity).to eq :medium
      expect(v.severity_level).to eq 2
    end

    it "extracts low from a CVSS v3.1 vector" do
      v = vuln("id"       => "CVE-2024-0004",
               "severity" => [{ "type"  => "CVSS_V3",
                                "score" => "CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N" }])
      expect(v.severity).to eq :low
      expect(v.severity_level).to eq 1
    end

    it "extracts critical from a CVSS v3.0 vector" do
      v = vuln("id"       => "CVE-2024-0005",
               "severity" => [{ "type"  => "CVSS_V3",
                                "score" => "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }])
      expect(v.severity).to eq :critical
    end

    it "returns nil severity for a zero-impact CVSS vector" do
      v = vuln("id"       => "CVE-2026-1234",
               "severity" => [{ "type"  => "CVSS_V4",
                                "score" => "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N" }])
      expect(v.severity).to be_nil
      expect(v.severity_display).to eq "UNKNOWN"
      expect(v.severity_level).to eq 0
    end

    it "returns nil severity for an invalid CVSS vector" do
      v = vuln("id" => "CVE-2024-0003", "severity" => [{ "type" => "CVSS_V3", "score" => "INVALID-CVSS" }])
      expect(v.severity).to be_nil
      expect(v.severity_display).to eq "UNKNOWN"
    end

    it "returns nil severity for an empty CVSS vector" do
      v = vuln("id" => "CVE-2024-0003", "severity" => [{ "type" => "CVSS_V3", "score" => "" }])
      expect(v.severity).to be_nil
    end

    it "falls back to database_specific when the CVSS vector cannot be scored" do
      v = vuln("id"                => "CVE-2024-0003",
               "severity"          => [{ "type" => "CVSS_V3", "score" => "INVALID-CVSS" }],
               "database_specific" => { "severity" => "HIGH" })
      expect(v.severity).to eq :high
    end

    # v4 is not scored locally so priority falls through to the v3 entry.
    it "falls through unsupported CVSS versions in priority order" do
      v = vuln("id"       => "CVE-2026-1234",
               "severity" => [
                 { "type" => "CVSS_V2", "score" => "AV:L/AC:H/Au:S/C:P/I:P/A:N" },
                 { "type"  => "CVSS_V4",
                   "score" => "CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N" },
                 { "type" => "CVSS_V3", "score" => "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
               ])
      expect(v.severity).to eq :critical
    end

    it "extracts severity from top-level database_specific" do
      v = vuln("id" => "CVE-2024-1234", "database_specific" => { "severity" => "HIGH" })
      expect(v.severity).to eq :high
    end

    it "extracts severity from affected database_specific" do
      v = vuln("id" => "CVE-2024-1234", "affected" => [{ "database_specific" => { "severity" => "MEDIUM" } }])
      expect(v.severity).to eq :medium
    end

    it "extracts severity from affected ecosystem_specific" do
      v = vuln("id" => "OSV-2023-1234", "affected" => [{ "ecosystem_specific" => { "severity" => "HIGH" } }])
      expect(v.severity).to eq :high
    end

    it "prefers ecosystem_specific over database_specific within an affected entry" do
      v = vuln("id"       => "OSV-2023-1234",
               "affected" => [{ "ecosystem_specific" => { "severity" => "HIGH" },
                                "database_specific"  => { "severity" => "LOW" } }])
      expect(v.severity).to eq :high
    end

    it "normalises MODERATE to medium" do
      v = vuln("id" => "CVE-2024-1234", "database_specific" => { "severity" => "MODERATE" })
      expect(v.severity).to eq :medium
      expect(v.severity_display).to eq "MEDIUM"
    end

    it "returns UNKNOWN when no severity source is present" do
      v = vuln("id" => "CVE-2024-1234")
      expect(v.severity).to be_nil
      expect(v.severity_display).to eq "UNKNOWN"
      expect(v.severity_level).to eq 0
    end
  end

  describe "#range_status" do
    def affected(ecosystem, name, *ranges, versions: nil)
      { "package" => { "ecosystem" => ecosystem, "name" => name },
        "ranges" => ranges, "versions" => versions }.compact
    end

    def range(type, *events)
      { "type" => type, "events" => events }
    end

    it "matches only the affected entry for the given package" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "0" }, { "fixed" => "2.28.1" })),
        affected("PyPI", "urllib3", range("ECOSYSTEM", { "introduced" => "0" }, { "fixed" => "1.26.5" })),
      ])
      expect(v.range_status("PyPI", "requests", "2.27.0"))
        .to have_attributes(affected?: true, fixed_in: "2.28.1")
      expect(v.range_status("PyPI", "urllib3", "2.27.0"))
        .to have_attributes(affected?: false, fixed_in: "1.26.5")
    end

    it "returns nil when no affected entry matches the package" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "other", range("ECOSYSTEM", { "fixed" => "1.0" })),
      ])
      expect(v.range_status("PyPI", "requests", "2.0")).to be_nil
    end

    it "skips GIT ranges as uncomparable and returns nil when nothing else is checkable" do
      v = vuln("id" => "CVE-2026-32316", "affected" => [
        affected("GIT", "https://github.com/jqlang/jq",
                 range("GIT", { "introduced" => "0" },
                       { "fixed" => "e47e56d226519635768e6aab2f38f0ab037c09e5" })),
      ])
      expect(v.range_status("GIT", "https://github.com/jqlang/jq", "1.8.1")).to be_nil
    end

    it "uses a comparable range when the same entry also carries a GIT range" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("GIT", "https://github.com/jqlang/jq",
                 range("GIT", { "fixed" => "e47e56d" }),
                 range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.8.2" })),
      ])
      expect(v.range_status("GIT", "https://github.com/jqlang/jq", "1.8.1"))
        .to have_attributes(affected?: true, fixed_in: "1.8.2")
    end

    it "unions all affected entries for the same package (Log4Shell has three)" do
      log4j = lambda do |intro, fixed|
        affected("Maven", "org.apache.logging.log4j:log4j-core",
                 range("ECOSYSTEM", { "introduced" => intro }, { "fixed" => fixed }))
      end
      v = vuln("id" => "GHSA-jfh8-c2jp-5v3q", "affected" => [
        log4j.call("2.0-beta9", "2.3.2"),
        log4j.call("2.4", "2.12.4"),
        log4j.call("2.13.0", "2.17.0"),
      ])
      expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.3.0"))
        .to have_attributes(state: :affected, fixed_in: "2.3.2")
      expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.10.0"))
        .to have_attributes(state: :affected, fixed_in: "2.12.4")
      expect(v.range_status("Maven", "org.apache.logging.log4j:log4j-core", "2.17.0"))
        .to have_attributes(state: :fixed, fixed_in: "2.17.0")
    end

    it "reports :not_applicable when the target is below every introduced boundary" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })),
      ])
      expect(v.range_status("PyPI", "requests", "2.31.0"))
        .to have_attributes(state: :not_applicable, fixed_in: nil)
    end

    it "does not report a last_affected boundary as fixed_in" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests",
                 range("ECOSYSTEM", { "introduced" => "0" }, { "last_affected" => "2.0" })),
      ])
      expect(v.range_status("PyPI", "requests", "2.0"))
        .to have_attributes(state: :affected, fixed_in: nil)
      expect(v.range_status("PyPI", "requests", "2.1"))
        .to have_attributes(state: :fixed, fixed_in: "2.0")
    end

    it "picks the fixed boundary of the interval containing the target across disjoint branches" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests",
                 range("ECOSYSTEM",
                       { "introduced" => "0" }, { "fixed" => "2.28.1" },
                       { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })),
      ])
      expect(v.range_status("PyPI", "requests", "3.0.1"))
        .to have_attributes(affected?: true, fixed_in: "3.0.4")
      expect(v.range_status("PyPI", "requests", "2.27.0"))
        .to have_attributes(affected?: true, fixed_in: "2.28.1")
    end

    it "reports :fixed with the highest fixed boundary at or below the target" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests",
                 range("ECOSYSTEM",
                       { "introduced" => "0" }, { "fixed" => "2.28.1" },
                       { "introduced" => "3.0.0" }, { "fixed" => "3.0.4" })),
      ])
      expect(v.range_status("PyPI", "requests", "2.31.0"))
        .to have_attributes(state: :fixed, fixed_in: "2.28.1")
      expect(v.range_status("PyPI", "requests", "3.1.0"))
        .to have_attributes(state: :fixed, fixed_in: "3.0.4")
    end

    it "reports affected with no fixed_in for an open-ended interval" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests", range("ECOSYSTEM", { "introduced" => "2.0.0" })),
      ])
      expect(v.range_status("PyPI", "requests", "2.31.0"))
        .to have_attributes(affected?: true, fixed_in: nil)
    end

    it "returns nil (not :not_applicable) when every comparison in the only range fails" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("crates.io", "serde",
                 range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.0.0" })),
      ])
      expect(v.range_status("crates.io", "serde", "not-semver")).to be_nil
    end

    it "compares SEMVER ranges with strict semver ordering" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("crates.io", "serde",
                 range("SEMVER", { "introduced" => "0" }, { "fixed" => "1.0.0" })),
      ])
      expect(v.range_status("crates.io", "serde", "1.0.0-rc.1"))
        .to have_attributes(affected?: true, fixed_in: "1.0.0")
    end

    it "checks an explicit versions list when present" do
      v = vuln("id" => "CVE-1", "affected" => [
        affected("PyPI", "requests", versions: ["2.30.0", "2.31.0"]),
      ])
      expect(v.range_status("PyPI", "requests", "2.31.0"))
        .to have_attributes(state: :affected, fixed_in: nil)
      expect(v.range_status("PyPI", "requests", "2.32.0"))
        .to have_attributes(state: :not_applicable, fixed_in: nil)
    end
  end

  describe "#identifiers and #cve_ids" do
    it "returns id followed by aliases" do
      v = vuln("id" => "GHSA-xxxx-yyyy-zzzz", "aliases" => ["CVE-2024-1234", "OSV-2024-1"])
      expect(v.identifiers).to eq ["GHSA-xxxx-yyyy-zzzz", "CVE-2024-1234", "OSV-2024-1"]
    end

    it "handles missing aliases" do
      expect(vuln("id" => "CVE-2024-1234").identifiers).to eq ["CVE-2024-1234"]
    end

    it "excludes upstream and related (directed references, not identities of this record)" do
      v = vuln("id" => "DEBIAN-CVE-2024-1234", "upstream" => ["CVE-2024-1234"],
               "related" => ["CVE-2024-9999"])
      expect(v.identifiers).to eq ["DEBIAN-CVE-2024-1234"]
      expect(v.upstream).to eq ["CVE-2024-1234"]
    end

    it "extracts CVE ids from id and aliases" do
      v = vuln("id" => "CVE-2024-1234", "aliases" => ["GHSA-xxxx-yyyy-zzzz", "CVE-2024-5678"])
      expect(v.cve_ids).to contain_exactly("CVE-2024-1234", "CVE-2024-5678")
    end
  end

  describe "#advisory_url and #fix_urls" do
    it "returns the first ADVISORY reference url" do
      v = vuln("id"         => "CVE-2024-1234",
               "references" => [
                 { "type" => "WEB", "url" => "https://example.com" },
                 { "type" => "ADVISORY", "url" => "https://security.example.com/advisory" },
               ])
      expect(v.advisory_url).to eq "https://security.example.com/advisory"
    end

    it "returns nil when no ADVISORY reference exists" do
      expect(vuln("id" => "CVE-2024-1234").advisory_url).to be_nil
    end

    it "returns all FIX reference urls" do
      v = vuln("id"         => "CVE-2024-1234",
               "references" => [
                 { "type" => "FIX", "url" => "https://github.com/example/commit/abc123" },
                 { "type" => "FIX", "url" => "https://github.com/example/commit/def456" },
                 { "type" => "WEB", "url" => "https://example.com" },
               ])
      expect(v.fix_urls).to contain_exactly(
        "https://github.com/example/commit/abc123",
        "https://github.com/example/commit/def456",
      )
    end
  end

  describe "#fixed_versions" do
    it "extracts fixed events from affected ranges" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "type"   => "SEMVER",
                                               "events" => [{ "introduced" => "0" },
                                                            { "fixed" => "v1.2.3" }] }] }])
      expect(v.fixed_versions).to eq ["v1.2.3"]
    end

    it "deduplicates" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "events" => [{ "fixed" => "v1.0.0" }] },
                                             { "events" => [{ "fixed" => "v1.0.0" }] }] }])
      expect(v.fixed_versions).to eq ["v1.0.0"]
    end
  end

  describe "#affects_version?" do
    it "returns true when there is no affected data" do
      expect(vuln("id" => "CVE-2024-1234").affects_version?("1.0.0")).to be true
    end

    it "matches explicit version lists, tolerating a v prefix" do
      v = vuln("id" => "CVE-2024-1234", "affected" => [{ "versions" => ["v1.0.0", "1.0.2"] }])
      expect(v.affects_version?("1.0.0")).to be true
      expect(v.affects_version?("v1.0.0")).to be true
      expect(v.affects_version?("1.0.2")).to be true
      expect(v.affects_version?("2.0.0")).to be false
    end

    it "checks introduced/fixed SEMVER ranges" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "1.0.0" },
                                                              { "fixed" => "1.5.0" })))
      expect(v.affects_version?("1.2.0")).to be true
      expect(v.affects_version?("1.4.9")).to be true
      expect(v.affects_version?("1.5.0")).to be false
      expect(v.affects_version?("2.0.0")).to be false
      expect(v.affects_version?("0.9.0")).to be false
    end

    it "matches an open-ended range introduced at 0" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" })))
      expect(v.affects_version?("0.1.0")).to be true
      expect(v.affects_version?("1.2.3")).to be true
    end

    it "matches an open-ended range introduced at v0" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "v0" })))
      expect(v.affects_version?("1.2.3")).to be true
    end

    it "handles limit events" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "limit" => "2.0.0" })))
      expect(v.affects_version?("1.9.9")).to be true
      expect(v.affects_version?("2.0.0")).to be false
    end

    it "treats a wildcard limit as unbounded above" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "1.0.0" },
                                                              { "limit" => "*" })))
      expect(v.affects_version?("1.0.0")).to be true
      expect(v.affects_version?("9.9.9")).to be true
      expect(v.affects_version?("0.9.9")).to be false
    end

    it "handles multiple intervals in one range" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "limit" => "2.0.0" },
                                                              { "introduced" => "3.0.0" })))
      expect(v.affects_version?("1.9.9")).to be true
      expect(v.affects_version?("2.0.0")).to be false
      expect(v.affects_version?("2.9.9")).to be false
      expect(v.affects_version?("3.0.0")).to be true
      expect(v.affects_version?("4.0.0")).to be true
    end

    it "handles last_affected events inclusively" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "1.0.0" },
                                                              { "last_affected" => "1.5.0" })))
      expect(v.affects_version?("1.5.0")).to be true
      expect(v.affects_version?("1.5.1")).to be false
    end

    it "checks SEMVER ranges regardless of package purl ecosystem" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "package" => { "purl" => "pkg:npm/lodash" },
                                "ranges"  => [{ "type"   => "SEMVER",
                                                "events" => [{ "introduced" => "0" },
                                                             { "fixed" => "4.17.21" }] }] }])
      expect(v.affects_version?("4.17.20")).to be true
      expect(v.affects_version?("4.17.21")).to be false
    end

    # GIT and ECOSYSTEM ranges cannot be evaluated locally; defer to OSV's
    # server-side match rather than dropping the finding.
    it "keeps a match when only non-SEMVER ranges are present" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "type"   => "GIT",
                                               "events" => [{ "introduced" => "abc123" },
                                                            { "fixed" => "def456" }] }] }])
      expect(v.affects_version?("1.0.0")).to be true
    end

    it "fails closed when SEMVER comparison raises" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "1.0.0" },
                                                              { "fixed" => "1.5.0" })))
      allow(Homebrew::Vulns::Semver).to receive(:compare).and_raise(StandardError, "boom")
      expect(v.affects_version?("1.2.0")).to be true
    end

    it "fails closed when the target version is not valid SemVer" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "fixed" => "3.0.1" })))
      expect(v.affects_version?("openssl-3.0.0")).to be true
    end

    it "fails closed when a range boundary is not valid SemVer" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "fixed" => "not.a.version" })))
      expect(v.affects_version?("1.0.0")).to be true
    end

    it "fails closed when the lower bound is unparseable and the upper bound excludes the target" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "bad" },
                                                              { "fixed" => "2.0.0" })))
      expect(v.affects_version?("3.0.0")).to be true
    end

    it "fails closed when the upper bound is unparseable and the lower bound excludes the target" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "2.0.0" },
                                                              { "fixed" => "bad" })))
      expect(v.affects_version?("1.0.0")).to be true
    end

    it "keeps a match when a non-matching explicit list coexists with an uncheckable range" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "versions" => ["1.0.0", "1.0.1"],
                                "ranges"   => [{ "type"   => "GIT",
                                                 "events" => [{ "introduced" => "abc123" },
                                                              { "fixed" => "def456" }] }] }])
      expect(v.affects_version?("2.0.0")).to be true
    end

    it "keeps a match when a non-matching SEMVER range coexists with an uncheckable range" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [
                 { "ranges" => [{ "type"   => "SEMVER",
                                  "events" => [{ "introduced" => "1.0.0" }, { "fixed" => "1.5.0" }] }] },
                 { "ranges" => [{ "type"   => "ECOSYSTEM",
                                  "events" => [{ "introduced" => "0" }, { "fixed" => "2.0.0" }] }] },
               ])
      expect(v.affects_version?("3.0.0")).to be true
    end
  end

  describe "#fix_available?" do
    it "returns true if a fix is available for the matched version in SEMVER range" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "fixed" => "1.0.0" })))
      expect(v.fix_available?("0.9.0")).to be true
      expect(v.fix_available?("1.0.0")).to be false
    end

    it "returns false if the matched interval is open-ended" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "fixed" => "1.0.0" },
                                                              { "introduced" => "2.0.0" })))
      expect(v.fix_available?("0.9.0")).to be true
      expect(v.fix_available?("2.1.0")).to be false
    end

    it "returns true if matched via non-SEMVER range with a fixed event" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "type"   => "GIT",
                                               "events" => [{ "introduced" => "abc123" },
                                                            { "fixed" => "def456" }] }] }])
      expect(v.fix_available?("1.0.0")).to be true
    end

    it "returns false if matched via non-SEMVER range without a fixed event" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "type"   => "GIT",
                                               "events" => [{ "introduced" => "abc123" }] }] }])
      expect(v.fix_available?("1.0.0")).to be false
    end

    it "returns true if matched via versions list and ranges has a fixed event" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "versions" => ["1.0.0", "1.0.1"],
                                "ranges"   => [{ "type"   => "SEMVER",
                                                 "events" => [{ "introduced" => "0" },
                                                              { "fixed" => "1.2.0" }] }] }])
      expect(v.fix_available?("1.0.0")).to be true
    end

    it "returns false if matched via versions list but sits outside every range" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "versions" => ["2.1.0"],
                                "ranges"   => [{ "type"   => "SEMVER",
                                                 "events" => [{ "introduced" => "0" },
                                                              { "fixed" => "1.2.0" }] }] }])
      expect(v.fix_available?("2.1.0")).to be false
    end

    it "returns false for an affected target ending at last_affected" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "1.0.0" },
                                                              { "last_affected" => "1.5.0" })))
      expect(v.fix_available?("1.5.0")).to be false
    end

    it "returns false if matched via non-SEMVER range ending at last_affected" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "type"   => "GIT",
                                               "events" => [{ "introduced" => "abc123" },
                                                            { "last_affected" => "def456" }] }] }])
      expect(v.fix_available?("1.0.0")).to be false
    end

    it "returns false for a non-SemVer target against a SEMVER fixed range" do
      v = vuln({ "id" => "CVE-2024-1234" }.merge(semver_range({ "introduced" => "0" },
                                                              { "fixed" => "3.0.1" })))
      expect(v.fix_available?("openssl-3.0.0")).to be false
    end

    it "checks non-SEMVER ranges even when the matched SEMVER interval has no fix" do
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{
                 "ranges" => [
                   { "type"   => "SEMVER",
                     "events" => [{ "introduced" => "0" }, { "fixed" => "1.0.0" },
                                  { "introduced" => "2.0.0" }] },
                   { "type"   => "GIT",
                     "events" => [{ "introduced" => "0" }, { "fixed" => "abc123" }] },
                 ],
               }])
      expect(v.fix_available?("3.0.0")).to be true
    end

    it "returns correct fix availability for a reopened GIT range" do
      # Semver-comparable GIT range
      v = vuln("id"       => "CVE-2024-1234",
               "affected" => [{ "ranges" => [{ "type"   => "GIT",
                                               "events" => [{ "introduced" => "1.0.0" },
                                                            { "fixed" => "2.0.0" },
                                                            { "introduced" => "3.0.0" }] }] }])
      expect(v.fix_available?("1.5.0")).to be true
      expect(v.fix_available?("3.5.0")).to be false

      v_non_semver = vuln("id"       => "CVE-2024-1234",
                          "affected" => [{ "ranges" => [{ "type"   => "GIT",
                                                          "events" => [{ "introduced" => "abc1234" },
                                                                       { "fixed" => "def5678" },
                                                                       { "introduced" => "ghi9012" }] }] }])
      expect(v_non_semver.fix_available?("1.0.0")).to be false
    end

    it "ignores affected entries for a different repository when repo_url is given" do
      nodejs_repo = "https://github.com/nodejs/node"
      openssl_repo = "https://github.com/openssl/openssl"

      v = vuln(
        "id"       => "CVE-2021-4044",
        "affected" => [
          {
            "package" => { "name" => nodejs_repo, "ecosystem" => "GIT" },
            "ranges"  => [{
              "type"   => "GIT",
              "repo"   => nodejs_repo,
              "events" => [{ "introduced" => "0" }, { "fixed" => "v17.2.0" }],
            }],
          },
          {
            "package" => { "name" => openssl_repo, "ecosystem" => "GIT" },
            "ranges"  => [{
              "type"   => "GIT",
              "repo"   => openssl_repo,
              "events" => [{ "introduced" => "openssl-3.0.0" }, { "last_affected" => "openssl-3.0.6" }],
            }],
          },
        ],
      )

      expect(v.fix_available?("openssl-3.0.0", openssl_repo)).to be false
      expect(v.fix_available?("1.0.0", nodejs_repo)).to be true
      expect(v.fix_available?("openssl-3.0.0")).to be true
    end

    it "ignores non-GIT ecosystem entries when repo_url is given" do
      openssl_repo = "https://github.com/openssl/openssl"
      v = vuln(
        "id"       => "GHSA-1234-5678-9012",
        "affected" => [
          {
            "package" => { "name" => openssl_repo, "ecosystem" => "GIT" },
            "ranges"  => [{
              "type"   => "GIT",
              "repo"   => openssl_repo,
              "events" => [{ "introduced" => "0" }],
            }],
          },
          {
            "package" => { "name" => "openssl", "ecosystem" => "PyPI" },
            "ranges"  => [{
              "type"   => "SEMVER",
              "events" => [{ "introduced" => "0" }, { "fixed" => "3.0.1" }],
            }],
          },
        ],
      )

      expect(v.fix_available?("3.0.0", openssl_repo)).to be false
    end

    it "handles trailing slashes in repo_url and range repo values" do
      openssl_repo = "https://github.com/openssl/openssl/"
      v = vuln(
        "id"       => "GHSA-1234-5678-9012",
        "affected" => [
          {
            "package" => { "name" => openssl_repo, "ecosystem" => "GIT" },
            "ranges"  => [{
              "type"   => "GIT",
              "repo"   => "https://github.com/openssl/openssl",
              "events" => [{ "introduced" => "0" }, { "fixed" => "3.0.1" }],
            }],
          },
        ],
      )

      expect(v.fix_available?("3.0.0", openssl_repo)).to be true
    end
  end

  describe ".from_osv_list" do
    it "wraps each hash in a Vulnerability" do
      list = described_class.from_osv_list([{ "id" => "CVE-2024-1111" }, { "id" => "CVE-2024-2222" }])
      expect(list.size).to eq 2
      expect(list.first).to be_a described_class
      expect(list.first.id).to eq "CVE-2024-1111"
    end
  end
end
