466 lines
23 KiB
C#
466 lines
23 KiB
C#
// -----------------------------------------------------------------------------
|
|
// DependencyReachabilityIntegrationTests.cs
|
|
// Sprint: SPRINT_20260119_022_Scanner_dependency_reachability
|
|
// Task: TASK-022-012 - Integration tests and accuracy measurement
|
|
// Description: Integration tests using realistic SBOM structures from npm, Maven, and Python
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Collections.Immutable;
|
|
using System.Text;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using StellaOps.Concelier.SbomIntegration.Models;
|
|
using StellaOps.Concelier.SbomIntegration.Parsing;
|
|
using StellaOps.Scanner.Reachability.Dependencies;
|
|
using StellaOps.TestKit;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.Reachability.Tests;
|
|
|
|
/// <summary>
|
|
/// Integration tests using realistic SBOM structures to validate reachability inference accuracy.
|
|
/// </summary>
|
|
public sealed class DependencyReachabilityIntegrationTests
|
|
{
|
|
private readonly ParsedSbomParser _parser;
|
|
|
|
public DependencyReachabilityIntegrationTests()
|
|
{
|
|
var loggerMock = new Mock<ILogger<ParsedSbomParser>>();
|
|
_parser = new ParsedSbomParser(loggerMock.Object);
|
|
}
|
|
|
|
#region npm Project Tests
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - npm project with deep dependencies
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_NpmProjectWithDeepDependencies_TracksTransitiveReachability()
|
|
{
|
|
// Arrange - Realistic npm project with lodash -> underscore chain
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "my-web-app",
|
|
"version": "1.0.0",
|
|
"bom-ref": "pkg:npm/my-web-app@1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:npm/express@4.18.2", "name": "express", "version": "4.18.2", "purl": "pkg:npm/express@4.18.2"},
|
|
{"type": "library", "bom-ref": "pkg:npm/body-parser@1.20.2", "name": "body-parser", "version": "1.20.2", "purl": "pkg:npm/body-parser@1.20.2"},
|
|
{"type": "library", "bom-ref": "pkg:npm/bytes@3.1.2", "name": "bytes", "version": "3.1.2", "purl": "pkg:npm/bytes@3.1.2"},
|
|
{"type": "library", "bom-ref": "pkg:npm/depd@2.0.0", "name": "depd", "version": "2.0.0", "purl": "pkg:npm/depd@2.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/jest@29.7.0", "name": "jest", "version": "29.7.0", "purl": "pkg:npm/jest@29.7.0", "scope": "optional"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:npm/my-web-app@1.0.0", "dependsOn": ["pkg:npm/express@4.18.2", "pkg:npm/jest@29.7.0"]},
|
|
{"ref": "pkg:npm/express@4.18.2", "dependsOn": ["pkg:npm/body-parser@1.20.2"]},
|
|
{"ref": "pkg:npm/body-parser@1.20.2", "dependsOn": ["pkg:npm/bytes@3.1.2", "pkg:npm/depd@2.0.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var policy = new ReachabilityPolicy
|
|
{
|
|
ScopeHandling = new ReachabilityScopePolicy
|
|
{
|
|
IncludeRuntime = true,
|
|
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
|
}
|
|
};
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy);
|
|
|
|
// Assert - Verify transitive dependencies are reachable
|
|
report.ComponentReachability["pkg:npm/express@4.18.2"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/body-parser@1.20.2"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/bytes@3.1.2"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/depd@2.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
|
|
// Test dependency (optional scope) should be potentially reachable
|
|
report.ComponentReachability["pkg:npm/jest@29.7.0"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
|
|
|
// Verify statistics
|
|
report.Statistics.TotalComponents.Should().BeGreaterThanOrEqualTo(5);
|
|
report.Statistics.ReachableComponents.Should().BeGreaterThanOrEqualTo(4);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Java/Maven Project Tests
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - Maven project with transitive dependencies
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_MavenProjectWithTransitiveDependencies_TracksAllPaths()
|
|
{
|
|
// Arrange - Realistic Maven project structure with Spring Boot
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "spring-boot-app",
|
|
"version": "3.2.0",
|
|
"bom-ref": "pkg:maven/com.example/spring-boot-app@3.2.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0", "name": "spring-boot-starter-web", "version": "3.2.0", "purl": "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0"},
|
|
{"type": "library", "bom-ref": "pkg:maven/org.springframework/spring-web@6.1.0", "name": "spring-web", "version": "6.1.0", "purl": "pkg:maven/org.springframework/spring-web@6.1.0"},
|
|
{"type": "library", "bom-ref": "pkg:maven/org.springframework/spring-core@6.1.0", "name": "spring-core", "version": "6.1.0", "purl": "pkg:maven/org.springframework/spring-core@6.1.0"},
|
|
{"type": "library", "bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0", "name": "jackson-databind", "version": "2.16.0", "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0"},
|
|
{"type": "library", "bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0", "name": "jackson-core", "version": "2.16.0", "purl": "pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0"},
|
|
{"type": "library", "bom-ref": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.0", "name": "junit-jupiter", "version": "5.10.0", "purl": "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.0", "scope": "optional"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:maven/com.example/spring-boot-app@3.2.0", "dependsOn": ["pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0", "pkg:maven/org.junit.jupiter/junit-jupiter@5.10.0"]},
|
|
{"ref": "pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0", "dependsOn": ["pkg:maven/org.springframework/spring-web@6.1.0", "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0"]},
|
|
{"ref": "pkg:maven/org.springframework/spring-web@6.1.0", "dependsOn": ["pkg:maven/org.springframework/spring-core@6.1.0"]},
|
|
{"ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0", "dependsOn": ["pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
|
|
|
// Assert - All runtime transitive dependencies should be reachable
|
|
report.ComponentReachability["pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.0"]
|
|
.Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:maven/org.springframework/spring-web@6.1.0"]
|
|
.Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:maven/org.springframework/spring-core@6.1.0"]
|
|
.Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.0"]
|
|
.Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:maven/com.fasterxml.jackson.core/jackson-core@2.16.0"]
|
|
.Should().Be(ReachabilityStatus.Reachable);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Python Project Tests
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - Python project with optional dependencies
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_PythonProjectWithOptionalDependencies_FiltersByScope()
|
|
{
|
|
// Arrange - Realistic Python project with Django and optional extras
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "django-api",
|
|
"version": "1.0.0",
|
|
"bom-ref": "pkg:pypi/django-api@1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:pypi/django@5.0", "name": "django", "version": "5.0", "purl": "pkg:pypi/django@5.0"},
|
|
{"type": "library", "bom-ref": "pkg:pypi/djangorestframework@3.14.0", "name": "djangorestframework", "version": "3.14.0", "purl": "pkg:pypi/djangorestframework@3.14.0"},
|
|
{"type": "library", "bom-ref": "pkg:pypi/pytz@2024.1", "name": "pytz", "version": "2024.1", "purl": "pkg:pypi/pytz@2024.1"},
|
|
{"type": "library", "bom-ref": "pkg:pypi/pytest@8.0.0", "name": "pytest", "version": "8.0.0", "purl": "pkg:pypi/pytest@8.0.0", "scope": "optional"},
|
|
{"type": "library", "bom-ref": "pkg:pypi/coverage@7.4.0", "name": "coverage", "version": "7.4.0", "purl": "pkg:pypi/coverage@7.4.0", "scope": "optional"},
|
|
{"type": "library", "bom-ref": "pkg:pypi/orphan-lib@1.0.0", "name": "orphan-lib", "version": "1.0.0", "purl": "pkg:pypi/orphan-lib@1.0.0"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:pypi/django-api@1.0.0", "dependsOn": ["pkg:pypi/django@5.0", "pkg:pypi/djangorestframework@3.14.0", "pkg:pypi/pytest@8.0.0"]},
|
|
{"ref": "pkg:pypi/django@5.0", "dependsOn": ["pkg:pypi/pytz@2024.1"]},
|
|
{"ref": "pkg:pypi/pytest@8.0.0", "dependsOn": ["pkg:pypi/coverage@7.4.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var policy = new ReachabilityPolicy
|
|
{
|
|
ScopeHandling = new ReachabilityScopePolicy
|
|
{
|
|
IncludeRuntime = true,
|
|
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
|
}
|
|
};
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy);
|
|
|
|
// Assert - Runtime deps should be reachable
|
|
report.ComponentReachability["pkg:pypi/django@5.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:pypi/djangorestframework@3.14.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:pypi/pytz@2024.1"].Should().Be(ReachabilityStatus.Reachable);
|
|
|
|
// Test deps should be potentially reachable
|
|
report.ComponentReachability["pkg:pypi/pytest@8.0.0"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
|
report.ComponentReachability["pkg:pypi/coverage@7.4.0"].Should().Be(ReachabilityStatus.PotentiallyReachable);
|
|
|
|
// Orphan (no dependency path) should be unreachable
|
|
report.ComponentReachability["pkg:pypi/orphan-lib@1.0.0"].Should().Be(ReachabilityStatus.Unreachable);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region False Positive Reduction Tests
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - Measure false positive reduction
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_SbomWithUnreachableVulnerabilities_CalculatesReductionMetrics()
|
|
{
|
|
// Arrange - SBOM with mix of reachable and unreachable components
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "test-app",
|
|
"version": "1.0.0",
|
|
"bom-ref": "pkg:npm/test-app@1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:npm/used-lib@1.0.0", "name": "used-lib", "version": "1.0.0", "purl": "pkg:npm/used-lib@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/unused-lib@1.0.0", "name": "unused-lib", "version": "1.0.0", "purl": "pkg:npm/unused-lib@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/another-unused@2.0.0", "name": "another-unused", "version": "2.0.0", "purl": "pkg:npm/another-unused@2.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/deep-dep@1.0.0", "name": "deep-dep", "version": "1.0.0", "purl": "pkg:npm/deep-dep@1.0.0"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:npm/test-app@1.0.0", "dependsOn": ["pkg:npm/used-lib@1.0.0"]},
|
|
{"ref": "pkg:npm/used-lib@1.0.0", "dependsOn": ["pkg:npm/deep-dep@1.0.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
|
|
|
// Assert - Verify statistics show reduction potential
|
|
// Note: Total includes the root app component from metadata
|
|
report.Statistics.TotalComponents.Should().Be(5); // 4 libs + 1 root app
|
|
report.Statistics.ReachableComponents.Should().Be(3); // root app + used-lib + deep-dep
|
|
report.Statistics.UnreachableComponents.Should().Be(2); // unused-lib and another-unused
|
|
|
|
// Verify specific components
|
|
report.ComponentReachability["pkg:npm/used-lib@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/deep-dep@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/unused-lib@1.0.0"].Should().Be(ReachabilityStatus.Unreachable);
|
|
report.ComponentReachability["pkg:npm/another-unused@2.0.0"].Should().Be(ReachabilityStatus.Unreachable);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Edge Case Tests
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - Diamond dependency pattern
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_DiamondDependencyPattern_MarksAllPathsReachable()
|
|
{
|
|
// Arrange - Classic diamond: A -> B, C; B -> D; C -> D
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "diamond-app",
|
|
"version": "1.0.0",
|
|
"bom-ref": "pkg:npm/diamond-app@1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:npm/left-branch@1.0.0", "name": "left-branch", "version": "1.0.0", "purl": "pkg:npm/left-branch@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/right-branch@1.0.0", "name": "right-branch", "version": "1.0.0", "purl": "pkg:npm/right-branch@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/shared-dep@1.0.0", "name": "shared-dep", "version": "1.0.0", "purl": "pkg:npm/shared-dep@1.0.0"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:npm/diamond-app@1.0.0", "dependsOn": ["pkg:npm/left-branch@1.0.0", "pkg:npm/right-branch@1.0.0"]},
|
|
{"ref": "pkg:npm/left-branch@1.0.0", "dependsOn": ["pkg:npm/shared-dep@1.0.0"]},
|
|
{"ref": "pkg:npm/right-branch@1.0.0", "dependsOn": ["pkg:npm/shared-dep@1.0.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
|
|
|
// Assert - All components should be reachable
|
|
report.ComponentReachability["pkg:npm/left-branch@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/right-branch@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/shared-dep@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
|
|
// Note: Statistics include the root app component
|
|
report.Statistics.ReachableComponents.Should().Be(4); // 3 libs + 1 root app
|
|
report.Statistics.UnreachableComponents.Should().Be(0);
|
|
}
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - Circular dependency detection
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_CircularDependency_HandlesWithoutInfiniteLoop()
|
|
{
|
|
// Arrange - Circular: A -> B -> C -> A
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "circular-app",
|
|
"version": "1.0.0",
|
|
"bom-ref": "pkg:npm/circular-app@1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:npm/lib-a@1.0.0", "name": "lib-a", "version": "1.0.0", "purl": "pkg:npm/lib-a@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/lib-b@1.0.0", "name": "lib-b", "version": "1.0.0", "purl": "pkg:npm/lib-b@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/lib-c@1.0.0", "name": "lib-c", "version": "1.0.0", "purl": "pkg:npm/lib-c@1.0.0"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:npm/circular-app@1.0.0", "dependsOn": ["pkg:npm/lib-a@1.0.0"]},
|
|
{"ref": "pkg:npm/lib-a@1.0.0", "dependsOn": ["pkg:npm/lib-b@1.0.0"]},
|
|
{"ref": "pkg:npm/lib-b@1.0.0", "dependsOn": ["pkg:npm/lib-c@1.0.0"]},
|
|
{"ref": "pkg:npm/lib-c@1.0.0", "dependsOn": ["pkg:npm/lib-a@1.0.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act - Should complete without hanging
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy: null);
|
|
|
|
// Assert - All in the cycle should be reachable
|
|
report.ComponentReachability["pkg:npm/lib-a@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/lib-b@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
report.ComponentReachability["pkg:npm/lib-c@1.0.0"].Should().Be(ReachabilityStatus.Reachable);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Accuracy Baseline Tests
|
|
|
|
// Sprint: SPRINT_20260119_022 TASK-022-012 - Establish accuracy baseline
|
|
[Trait("Category", TestCategories.Integration)]
|
|
[Fact]
|
|
public async Task Analyze_KnownScenario_MatchesExpectedResults()
|
|
{
|
|
// Arrange - Controlled scenario with known expected results
|
|
var sbomJson = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "accuracy-test",
|
|
"version": "1.0.0",
|
|
"bom-ref": "pkg:npm/accuracy-test@1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{"type": "library", "bom-ref": "pkg:npm/runtime-a@1.0.0", "name": "runtime-a", "version": "1.0.0", "purl": "pkg:npm/runtime-a@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/runtime-b@1.0.0", "name": "runtime-b", "version": "1.0.0", "purl": "pkg:npm/runtime-b@1.0.0"},
|
|
{"type": "library", "bom-ref": "pkg:npm/dev-only@1.0.0", "name": "dev-only", "version": "1.0.0", "purl": "pkg:npm/dev-only@1.0.0", "scope": "optional"},
|
|
{"type": "library", "bom-ref": "pkg:npm/orphan@1.0.0", "name": "orphan", "version": "1.0.0", "purl": "pkg:npm/orphan@1.0.0"}
|
|
],
|
|
"dependencies": [
|
|
{"ref": "pkg:npm/accuracy-test@1.0.0", "dependsOn": ["pkg:npm/runtime-a@1.0.0", "pkg:npm/dev-only@1.0.0"]},
|
|
{"ref": "pkg:npm/runtime-a@1.0.0", "dependsOn": ["pkg:npm/runtime-b@1.0.0"]}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(sbomJson));
|
|
var parsedSbom = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
var policy = new ReachabilityPolicy
|
|
{
|
|
ScopeHandling = new ReachabilityScopePolicy
|
|
{
|
|
IncludeRuntime = true,
|
|
IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable
|
|
}
|
|
};
|
|
|
|
var combiner = new ReachGraphReachabilityCombiner();
|
|
|
|
// Act
|
|
var report = combiner.Analyze(parsedSbom, callGraph: null, policy);
|
|
|
|
// Assert - Verify exact expected outcomes
|
|
var expected = new Dictionary<string, ReachabilityStatus>
|
|
{
|
|
["pkg:npm/runtime-a@1.0.0"] = ReachabilityStatus.Reachable,
|
|
["pkg:npm/runtime-b@1.0.0"] = ReachabilityStatus.Reachable,
|
|
["pkg:npm/dev-only@1.0.0"] = ReachabilityStatus.PotentiallyReachable,
|
|
["pkg:npm/orphan@1.0.0"] = ReachabilityStatus.Unreachable
|
|
};
|
|
|
|
foreach (var (purl, expectedStatus) in expected)
|
|
{
|
|
report.ComponentReachability[purl].Should().Be(expectedStatus,
|
|
because: $"component {purl} should have status {expectedStatus}");
|
|
}
|
|
|
|
// Verify no false negatives (reachable marked as unreachable)
|
|
report.ComponentReachability
|
|
.Where(kv => kv.Value == ReachabilityStatus.Unreachable)
|
|
.Should().OnlyContain(kv => kv.Key == "pkg:npm/orphan@1.0.0",
|
|
because: "only the orphan component should be unreachable");
|
|
}
|
|
|
|
#endregion
|
|
}
|