using System.Collections.Immutable; using FluentAssertions; using StellaOps.Policy.Engine.SelectionJoin; using Xunit; namespace StellaOps.Policy.Engine.Tests.SelectionJoin; public sealed class SelectionJoinTests { #region PurlEquivalence Tests [Theory] [InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash")] [InlineData("pkg:maven/org.apache.commons/commons-lang3@3.12.0", "pkg:maven/org.apache.commons/commons-lang3")] [InlineData("pkg:pypi/requests@2.28.0", "pkg:pypi/requests")] [InlineData("pkg:gem/rails@7.0.0", "pkg:gem/rails")] [InlineData("pkg:nuget/Newtonsoft.Json@13.0.1", "pkg:nuget/Newtonsoft.Json")] public void ExtractPackageKey_RemovesVersion(string purl, string expectedKey) { var key = PurlEquivalence.ExtractPackageKey(purl); key.Should().Be(expectedKey); } [Fact] public void ExtractPackageKey_HandlesNoVersion() { var purl = "pkg:npm/lodash"; var key = PurlEquivalence.ExtractPackageKey(purl); key.Should().Be("pkg:npm/lodash"); } [Fact] public void ExtractPackageKey_HandlesScopedPackages() { var purl = "pkg:npm/@scope/package@1.0.0"; var key = PurlEquivalence.ExtractPackageKey(purl); key.Should().Be("pkg:npm/@scope/package"); } [Theory] [InlineData("pkg:npm/lodash@4.17.21", "npm")] [InlineData("pkg:maven/org.apache/commons@1.0", "maven")] [InlineData("pkg:pypi/requests@2.28", "pypi")] public void ExtractEcosystem_ReturnsCorrectEcosystem(string purl, string expected) { var ecosystem = PurlEquivalence.ExtractEcosystem(purl); ecosystem.Should().Be(expected); } [Fact] public void ComputeMatchConfidence_ExactMatch_Returns1() { var confidence = PurlEquivalence.ComputeMatchConfidence( "pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.21"); confidence.Should().Be(1.0); } [Fact] public void ComputeMatchConfidence_PackageKeyMatch_Returns08() { var confidence = PurlEquivalence.ComputeMatchConfidence( "pkg:npm/lodash@4.17.21", "pkg:npm/lodash@4.17.20"); confidence.Should().Be(0.8); } #endregion #region PurlEquivalenceTable Tests [Fact] public void FromGroups_CreatesEquivalentMappings() { var groups = new[] { new[] { "pkg:npm/lodash", "pkg:npm/lodash-es" } }; var table = PurlEquivalenceTable.FromGroups(groups); table.AreEquivalent("pkg:npm/lodash", "pkg:npm/lodash-es").Should().BeTrue(); table.GroupCount.Should().Be(1); } [Fact] public void GetCanonical_ReturnsFirstLexicographically() { var groups = new[] { new[] { "pkg:npm/b-package", "pkg:npm/a-package" } }; var table = PurlEquivalenceTable.FromGroups(groups); // "a-package" is lexicographically first table.GetCanonical("pkg:npm/b-package").Should().Be("pkg:npm/a-package"); } [Fact] public void GetEquivalents_ReturnsAllEquivalentPurls() { var groups = new[] { new[] { "pkg:npm/a", "pkg:npm/b", "pkg:npm/c" } }; var table = PurlEquivalenceTable.FromGroups(groups); var equivalents = table.GetEquivalents("pkg:npm/b"); equivalents.Should().HaveCount(3); equivalents.Should().Contain("pkg:npm/a"); equivalents.Should().Contain("pkg:npm/b"); equivalents.Should().Contain("pkg:npm/c"); } [Fact] public void Empty_HasNoMappings() { var table = PurlEquivalenceTable.Empty; table.GroupCount.Should().Be(0); table.TotalEntries.Should().Be(0); table.AreEquivalent("pkg:npm/a", "pkg:npm/b").Should().BeFalse(); } #endregion #region SelectionJoinService Tests [Fact] public void ResolveTuples_MatchesByExactPurl() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: [ new SbomComponentInput( Purl: "pkg:npm/lodash@4.17.21", Name: "lodash", Version: "4.17.21", Ecosystem: "npm", Metadata: ImmutableDictionary.Empty) ], Advisories: [ new AdvisoryLinksetInput( AdvisoryId: "GHSA-test-001", Source: "github", Purls: ["pkg:npm/lodash@4.17.21"], Cpes: ImmutableArray.Empty, Aliases: ["CVE-2021-12345"], Confidence: 1.0) ], VexLinksets: ImmutableArray.Empty, EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); result.Tuples.Should().ContainSingle(); result.Tuples[0].MatchType.Should().Be(SelectionMatchType.ExactPurl); result.Tuples[0].Component.Purl.Should().Be("pkg:npm/lodash@4.17.21"); result.Statistics.ExactPurlMatches.Should().Be(1); } [Fact] public void ResolveTuples_MatchesByPackageKey() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: [ new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm", ImmutableDictionary.Empty) ], Advisories: [ new AdvisoryLinksetInput("GHSA-test-001", "github", Purls: ["pkg:npm/lodash@4.17.20"], // Different version Cpes: ImmutableArray.Empty, Aliases: ["CVE-2021-12345"], Confidence: 1.0) ], VexLinksets: ImmutableArray.Empty, EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); result.Tuples.Should().ContainSingle(); result.Tuples[0].MatchType.Should().Be(SelectionMatchType.PackageKeyMatch); } [Fact] public void ResolveTuples_AppliesVexOverlay() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: [ new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm", ImmutableDictionary.Empty) ], Advisories: [ new AdvisoryLinksetInput("GHSA-test-001", "github", Purls: ["pkg:npm/lodash@4.17.21"], Cpes: ImmutableArray.Empty, Aliases: ["CVE-2021-12345"], Confidence: 1.0) ], VexLinksets: [ new VexLinksetInput("vex-1", "CVE-2021-12345", "pkg:npm/lodash@4.17.21", "not_affected", "vulnerable_code_not_in_execute_path", VexConfidenceLevel.High) ], EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); result.Tuples.Should().ContainSingle(); result.Tuples[0].Vex.Should().NotBeNull(); result.Tuples[0].Vex!.Status.Should().Be("not_affected"); result.Statistics.VexOverlays.Should().Be(1); } [Fact] public void ResolveTuples_ProducesDeterministicOrdering() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: [ new SbomComponentInput("pkg:npm/z-package@1.0.0", "z", "1.0.0", "npm", ImmutableDictionary.Empty), new SbomComponentInput("pkg:npm/a-package@1.0.0", "a", "1.0.0", "npm", ImmutableDictionary.Empty), new SbomComponentInput("pkg:npm/m-package@1.0.0", "m", "1.0.0", "npm", ImmutableDictionary.Empty) ], Advisories: [ new AdvisoryLinksetInput("ADV-001", "test", Purls: ["pkg:npm/z-package", "pkg:npm/a-package", "pkg:npm/m-package"], Cpes: ImmutableArray.Empty, Aliases: ["CVE-2021-001"], Confidence: 1.0) ], VexLinksets: ImmutableArray.Empty, EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); // Should be sorted by component PURL result.Tuples.Should().HaveCount(3); result.Tuples[0].Component.Purl.Should().Be("pkg:npm/a-package@1.0.0"); result.Tuples[1].Component.Purl.Should().Be("pkg:npm/m-package@1.0.0"); result.Tuples[2].Component.Purl.Should().Be("pkg:npm/z-package@1.0.0"); } [Fact] public void ResolveTuples_HandlesMultipleAdvisories() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: [ new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm", ImmutableDictionary.Empty) ], Advisories: [ new AdvisoryLinksetInput("ADV-001", "test", Purls: ["pkg:npm/lodash@4.17.21"], Cpes: ImmutableArray.Empty, Aliases: ["CVE-2021-001"], Confidence: 1.0), new AdvisoryLinksetInput("ADV-002", "test", Purls: ["pkg:npm/lodash@4.17.21"], Cpes: ImmutableArray.Empty, Aliases: ["CVE-2021-002"], Confidence: 1.0) ], VexLinksets: ImmutableArray.Empty, EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); result.Tuples.Should().HaveCount(2); result.Tuples.Should().Contain(t => t.Advisory.AdvisoryId == "ADV-001"); result.Tuples.Should().Contain(t => t.Advisory.AdvisoryId == "ADV-002"); } [Fact] public void ResolveTuples_ReturnsStatistics() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: [ new SbomComponentInput("pkg:npm/a@1.0.0", "a", "1.0.0", "npm", ImmutableDictionary.Empty), new SbomComponentInput("pkg:npm/b@1.0.0", "b", "1.0.0", "npm", ImmutableDictionary.Empty) ], Advisories: [ new AdvisoryLinksetInput("ADV-001", "test", Purls: ["pkg:npm/a"], Cpes: ImmutableArray.Empty, Aliases: ["CVE-001"], Confidence: 1.0) ], VexLinksets: ImmutableArray.Empty, EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); result.Statistics.TotalComponents.Should().Be(2); result.Statistics.TotalAdvisories.Should().Be(1); result.Statistics.MatchedTuples.Should().Be(1); result.UnmatchedComponents.Should().ContainSingle(c => c.Purl == "pkg:npm/b@1.0.0"); } [Fact] public void ResolveTuples_HandlesEmptyInput() { var service = new SelectionJoinService(); var input = new SelectionJoinBatchInput( TenantId: "test-tenant", BatchId: "batch-1", Components: ImmutableArray.Empty, Advisories: ImmutableArray.Empty, VexLinksets: ImmutableArray.Empty, EquivalenceTable: null, Options: new SelectionJoinOptions()); var result = service.ResolveTuples(input); result.Tuples.Should().BeEmpty(); result.Statistics.TotalComponents.Should().Be(0); } #endregion #region SelectionJoinTuple Tests [Fact] public void CreateTupleId_IsDeterministic() { var id1 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345"); var id2 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345"); id1.Should().Be(id2); id1.Should().StartWith("tuple:sha256:"); } [Fact] public void CreateTupleId_NormalizesInput() { var id1 = SelectionJoinTuple.CreateTupleId("TENANT1", "PKG:NPM/LODASH@4.17.21", "CVE-2021-12345"); var id2 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345"); id1.Should().Be(id2); } #endregion }