using System.Collections.Immutable; using System.Linq; using FluentAssertions; using StellaOps.Concelier.SbomIntegration.Models; using StellaOps.Scanner.Reachability.Dependencies; using StellaOps.TestKit; using Xunit; using static StellaOps.Scanner.Reachability.Tests.DependencyTestData; namespace StellaOps.Scanner.Reachability.Tests; public sealed class DependencyGraphBuilderTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Build_UsesMetadataRootAndDependencies() { var sbom = BuildSbom( components: [ Component("app", type: "application"), Component("lib-a"), Component("lib-b") ], dependencies: [ Dependency("app", ["lib-a"], DependencyScope.Runtime), Dependency("lib-a", ["lib-b"], DependencyScope.Optional) ], rootRef: "app"); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); graph.Nodes.Should().Contain(new[] { "app", "lib-a", "lib-b" }); graph.Edges.Should().ContainKey("app"); graph.Edges["app"].Should().ContainSingle(edge => edge.From == "app" && edge.To == "lib-a" && edge.Scope == DependencyScope.Runtime); graph.Edges["lib-a"].Should().ContainSingle(edge => edge.From == "lib-a" && edge.To == "lib-b" && edge.Scope == DependencyScope.Optional); graph.Roots.Should().ContainSingle().Which.Should().Be("app"); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Linear chain test [Trait("Category", TestCategories.Unit)] [Fact] public void Build_LinearChain_CreatesCorrectGraph() { var sbom = BuildSbom( components: [ Component("a", type: "application"), Component("b"), Component("c"), Component("d") ], dependencies: [ Dependency("a", ["b"], DependencyScope.Runtime), Dependency("b", ["c"], DependencyScope.Runtime), Dependency("c", ["d"], DependencyScope.Runtime) ], rootRef: "a"); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); graph.Nodes.Should().HaveCount(4); graph.Edges["a"].Should().ContainSingle(e => e.To == "b"); graph.Edges["b"].Should().ContainSingle(e => e.To == "c"); graph.Edges["c"].Should().ContainSingle(e => e.To == "d"); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Diamond dependency test [Trait("Category", TestCategories.Unit)] [Fact] public void Build_DiamondDependency_CreatesCorrectGraph() { // Diamond: A -> B -> D // A -> C -> D var sbom = BuildSbom( components: [ Component("a", type: "application"), Component("b"), Component("c"), Component("d") ], dependencies: [ Dependency("a", ["b", "c"], DependencyScope.Runtime), Dependency("b", ["d"], DependencyScope.Runtime), Dependency("c", ["d"], DependencyScope.Runtime) ], rootRef: "a"); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); graph.Nodes.Should().HaveCount(4); graph.Edges["a"].Should().HaveCount(2); graph.Edges["b"].Should().ContainSingle(e => e.To == "d"); graph.Edges["c"].Should().ContainSingle(e => e.To == "d"); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Circular dependency test [Trait("Category", TestCategories.Unit)] [Fact] public void Build_CircularDependency_HandlesCorrectly() { // Circular: A -> B -> C -> A var sbom = BuildSbom( components: [ Component("a", type: "application"), Component("b"), Component("c") ], dependencies: [ Dependency("a", ["b"], DependencyScope.Runtime), Dependency("b", ["c"], DependencyScope.Runtime), Dependency("c", ["a"], DependencyScope.Runtime) ], rootRef: "a"); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); graph.Nodes.Should().HaveCount(3); graph.Edges["a"].Should().ContainSingle(e => e.To == "b"); graph.Edges["b"].Should().ContainSingle(e => e.To == "c"); graph.Edges["c"].Should().ContainSingle(e => e.To == "a"); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Empty SBOM test [Trait("Category", TestCategories.Unit)] [Fact] public void Build_EmptySbom_ReturnsEmptyGraph() { var sbom = BuildSbom( components: [], dependencies: []); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); graph.Nodes.Should().BeEmpty(); graph.Edges.Should().BeEmpty(); graph.Roots.Should().BeEmpty(); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Multiple roots test [Trait("Category", TestCategories.Unit)] [Fact] public void Build_MultipleRoots_DetectsAllRoots() { var sbom = BuildSbom( components: [ Component("app-1", type: "application"), Component("app-2", type: "application"), Component("shared-lib") ], dependencies: [ Dependency("app-1", ["shared-lib"], DependencyScope.Runtime), Dependency("app-2", ["shared-lib"], DependencyScope.Runtime) ]); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); graph.Roots.Should().BeEquivalentTo(["app-1", "app-2"]); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Missing dependency target [Trait("Category", TestCategories.Unit)] [Fact] public void Build_MissingDependencyTarget_HandlesGracefully() { var sbom = BuildSbom( components: [ Component("app", type: "application") ], dependencies: [ Dependency("app", ["missing-lib"], DependencyScope.Runtime) ], rootRef: "app"); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); // Should still build graph even with missing target graph.Nodes.Should().Contain("app"); graph.Edges["app"].Should().ContainSingle(e => e.To == "missing-lib"); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Mixed scope dependencies [Trait("Category", TestCategories.Unit)] [Fact] public void Build_MixedScopes_PreservesAllScopes() { var sbom = BuildSbom( components: [ Component("app", type: "application"), Component("runtime-lib"), Component("dev-lib"), Component("test-lib"), Component("optional-lib") ], dependencies: [ Dependency("app", ["runtime-lib"], DependencyScope.Runtime), Dependency("app", ["dev-lib"], DependencyScope.Development), Dependency("app", ["test-lib"], DependencyScope.Test), Dependency("app", ["optional-lib"], DependencyScope.Optional) ], rootRef: "app"); var builder = new DependencyGraphBuilder(); var graph = builder.Build(sbom); var edges = graph.Edges["app"]; edges.Should().HaveCount(4); edges.Should().Contain(e => e.To == "runtime-lib" && e.Scope == DependencyScope.Runtime); edges.Should().Contain(e => e.To == "dev-lib" && e.Scope == DependencyScope.Development); edges.Should().Contain(e => e.To == "test-lib" && e.Scope == DependencyScope.Test); edges.Should().Contain(e => e.To == "optional-lib" && e.Scope == DependencyScope.Optional); } } public sealed class EntryPointDetectorTests { [Trait("Category", TestCategories.Unit)] [Fact] public void DetectEntryPoints_IncludesPolicyAndSbomSignals() { var sbom = BuildSbom( components: [ Component("root", type: "application"), Component("worker", type: "application") ], dependencies: [], rootRef: "root"); var policy = new ReachabilityPolicy { EntryPoints = new ReachabilityEntryPointPolicy { Additional = ["extra-entry"] } }; var detector = new EntryPointDetector(); var entryPoints = detector.DetectEntryPoints(sbom, policy); entryPoints.Should().Contain(new[] { "extra-entry", "root", "worker" }); } [Trait("Category", TestCategories.Unit)] [Fact] public void DetectEntryPoints_FallsBackToAllComponents() { var sbom = BuildSbom( components: [ Component("lib-a", type: "library"), Component("lib-b", type: "library") ], dependencies: []); var detector = new EntryPointDetector(); var entryPoints = detector.DetectEntryPoints(sbom); entryPoints.Should().BeEquivalentTo(new[] { "lib-a", "lib-b" }); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Policy disables SBOM detection [Trait("Category", TestCategories.Unit)] [Fact] public void DetectEntryPoints_PolicyDisablesSbomDetection_OnlyUsesAdditional() { var sbom = BuildSbom( components: [ Component("app", type: "application"), Component("lib") ], dependencies: [], rootRef: "app"); var policy = new ReachabilityPolicy { EntryPoints = new ReachabilityEntryPointPolicy { DetectFromSbom = false, Additional = ["custom-entry"] } }; var detector = new EntryPointDetector(); var entryPoints = detector.DetectEntryPoints(sbom, policy); entryPoints.Should().ContainSingle("custom-entry"); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Empty SBOM entry points [Trait("Category", TestCategories.Unit)] [Fact] public void DetectEntryPoints_EmptySbom_ReturnsEmpty() { var sbom = BuildSbom( components: [], dependencies: []); var detector = new EntryPointDetector(); var entryPoints = detector.DetectEntryPoints(sbom); entryPoints.Should().BeEmpty(); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Entry points from container type [Trait("Category", TestCategories.Unit)] [Fact] public void DetectEntryPoints_ContainerComponent_TreatedAsEntryPoint() { var sbom = BuildSbom( components: [ Component("my-container", type: "container"), Component("lib") ], dependencies: [ Dependency("my-container", ["lib"], DependencyScope.Runtime) ]); var detector = new EntryPointDetector(); var entryPoints = detector.DetectEntryPoints(sbom); entryPoints.Should().Contain("my-container"); } } public sealed class StaticReachabilityAnalyzerTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_RespectsScopeHandling() { var graph = new DependencyGraph { Nodes = ["app", "runtime-lib", "dev-lib", "optional-lib"], Edges = new Dictionary> { ["app"] = [ new DependencyEdge { From = "app", To = "runtime-lib", Scope = DependencyScope.Runtime }, new DependencyEdge { From = "app", To = "optional-lib", Scope = DependencyScope.Optional } ], ["runtime-lib"] = [ new DependencyEdge { From = "runtime-lib", To = "dev-lib", Scope = DependencyScope.Development } ] }.ToImmutableDictionary(StringComparer.Ordinal) }; var policy = new ReachabilityPolicy { ScopeHandling = new ReachabilityScopePolicy { IncludeRuntime = true, IncludeOptional = OptionalDependencyHandling.AsPotentiallyReachable, IncludeDevelopment = false, IncludeTest = false } }; var analyzer = new StaticReachabilityAnalyzer(); var report = analyzer.Analyze(graph, ["app"], policy); report.ComponentReachability["app"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["runtime-lib"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["optional-lib"].Should().Be(ReachabilityStatus.PotentiallyReachable); report.ComponentReachability["dev-lib"].Should().Be(ReachabilityStatus.Unreachable); report.Findings.Should().Contain(finding => finding.ComponentRef == "optional-lib" && finding.Path.SequenceEqual(new[] { "app", "optional-lib" })); } [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_WithoutEntryPoints_MarksUnknown() { var graph = new DependencyGraph { Nodes = ["lib-a", "lib-b"] }; var analyzer = new StaticReachabilityAnalyzer(); var report = analyzer.Analyze(graph, [], null); report.ComponentReachability["lib-a"].Should().Be(ReachabilityStatus.Unknown); report.ComponentReachability["lib-b"].Should().Be(ReachabilityStatus.Unknown); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Circular dependency traversal [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_CircularDependency_MarksAllReachable() { // Circular: A -> B -> C -> A var graph = new DependencyGraph { Nodes = ["a", "b", "c"], Edges = new Dictionary> { ["a"] = [new DependencyEdge { From = "a", To = "b", Scope = DependencyScope.Runtime }], ["b"] = [new DependencyEdge { From = "b", To = "c", Scope = DependencyScope.Runtime }], ["c"] = [new DependencyEdge { From = "c", To = "a", Scope = DependencyScope.Runtime }] }.ToImmutableDictionary(StringComparer.Ordinal), Roots = ["a"] }; var analyzer = new StaticReachabilityAnalyzer(); var report = analyzer.Analyze(graph, ["a"], null); report.ComponentReachability["a"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["b"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["c"].Should().Be(ReachabilityStatus.Reachable); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Multiple entry points [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_MultipleEntryPoints_MarksAllReachablePaths() { // Entry1 -> A, Entry2 -> B, A and B are independent var graph = new DependencyGraph { Nodes = ["entry1", "entry2", "a", "b", "orphan"], Edges = new Dictionary> { ["entry1"] = [new DependencyEdge { From = "entry1", To = "a", Scope = DependencyScope.Runtime }], ["entry2"] = [new DependencyEdge { From = "entry2", To = "b", Scope = DependencyScope.Runtime }] }.ToImmutableDictionary(StringComparer.Ordinal) }; var analyzer = new StaticReachabilityAnalyzer(); var report = analyzer.Analyze(graph, ["entry1", "entry2"], null); report.ComponentReachability["entry1"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["entry2"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["a"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["b"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["orphan"].Should().Be(ReachabilityStatus.Unreachable); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Test scope handling [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_TestScopeExcluded_MarksUnreachable() { var graph = new DependencyGraph { Nodes = ["app", "runtime-lib", "test-lib"], Edges = new Dictionary> { ["app"] = [ new DependencyEdge { From = "app", To = "runtime-lib", Scope = DependencyScope.Runtime }, new DependencyEdge { From = "app", To = "test-lib", Scope = DependencyScope.Test } ] }.ToImmutableDictionary(StringComparer.Ordinal) }; var policy = new ReachabilityPolicy { ScopeHandling = new ReachabilityScopePolicy { IncludeRuntime = true, IncludeTest = false } }; var analyzer = new StaticReachabilityAnalyzer(); var report = analyzer.Analyze(graph, ["app"], policy); report.ComponentReachability["app"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["runtime-lib"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["test-lib"].Should().Be(ReachabilityStatus.Unreachable); } // Sprint: SPRINT_20260119_022 TASK-022-011 - Deep transitive dependencies [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_DeepTransitiveDependencies_MarksAllReachable() { // 5-level deep: app -> a -> b -> c -> d -> e var graph = new DependencyGraph { Nodes = ["app", "a", "b", "c", "d", "e"], Edges = new Dictionary> { ["app"] = [new DependencyEdge { From = "app", To = "a", Scope = DependencyScope.Runtime }], ["a"] = [new DependencyEdge { From = "a", To = "b", Scope = DependencyScope.Runtime }], ["b"] = [new DependencyEdge { From = "b", To = "c", Scope = DependencyScope.Runtime }], ["c"] = [new DependencyEdge { From = "c", To = "d", Scope = DependencyScope.Runtime }], ["d"] = [new DependencyEdge { From = "d", To = "e", Scope = DependencyScope.Runtime }] }.ToImmutableDictionary(StringComparer.Ordinal) }; var analyzer = new StaticReachabilityAnalyzer(); var report = analyzer.Analyze(graph, ["app"], null); foreach (var node in graph.Nodes) { report.ComponentReachability[node].Should().Be(ReachabilityStatus.Reachable, because: $"node {node} should be reachable from app"); } } } public sealed class ConditionalReachabilityAnalyzerTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_MarksConditionalDependenciesAndConditions() { var properties = ImmutableDictionary.Empty .Add("stellaops.reachability.condition", "feature:beta"); var sbom = BuildSbom( components: [ Component("app", type: "application"), Component("optional-lib", scope: ComponentScope.Optional), Component("flagged-lib", properties: properties) ], dependencies: [ Dependency("app", ["optional-lib"], DependencyScope.Optional), Dependency("optional-lib", ["flagged-lib"], DependencyScope.Runtime) ], rootRef: "app"); var graph = new DependencyGraphBuilder().Build(sbom); var entryPoints = new EntryPointDetector().DetectEntryPoints(sbom); var analyzer = new ConditionalReachabilityAnalyzer(); var report = analyzer.Analyze(graph, sbom, entryPoints); report.ComponentReachability["app"].Should().Be(ReachabilityStatus.Reachable); report.ComponentReachability["optional-lib"].Should() .Be(ReachabilityStatus.PotentiallyReachable); report.ComponentReachability["flagged-lib"].Should() .Be(ReachabilityStatus.PotentiallyReachable); report.Findings.Single(finding => finding.ComponentRef == "flagged-lib") .Conditions.Should().Equal( "component.scope.optional", "dependency.scope.optional", "feature:beta"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Analyze_PromotesToReachableWhenUnconditionalPathExists() { var sbom = BuildSbom( components: [ Component("app", type: "application"), Component("lib-a") ], dependencies: [ Dependency("app", ["lib-a"], DependencyScope.Optional), Dependency("app", ["lib-a"], DependencyScope.Runtime) ], rootRef: "app"); var graph = new DependencyGraphBuilder().Build(sbom); var entryPoints = new EntryPointDetector().DetectEntryPoints(sbom); var analyzer = new ConditionalReachabilityAnalyzer(); var report = analyzer.Analyze(graph, sbom, entryPoints); report.ComponentReachability["lib-a"].Should().Be(ReachabilityStatus.Reachable); report.Findings.Single(finding => finding.ComponentRef == "lib-a") .Conditions.Should().BeEmpty(); } } internal static class DependencyTestData { public static ParsedSbom BuildSbom( ImmutableArray components, ImmutableArray dependencies, string? rootRef = null) { return new ParsedSbom { Format = "cyclonedx", SpecVersion = "1.7", SerialNumber = "urn:uuid:reachability-test", Components = components, Dependencies = dependencies, Metadata = new ParsedSbomMetadata { RootComponentRef = rootRef } }; } public static ParsedComponent Component( string bomRef, string? type = null, ComponentScope scope = ComponentScope.Required, ImmutableDictionary? properties = null, string? purl = null) { return new ParsedComponent { BomRef = bomRef, Name = bomRef, Type = type, Scope = scope, Properties = properties ?? ImmutableDictionary.Empty, Purl = purl }; } public static ParsedDependency Dependency( string source, ImmutableArray dependsOn, DependencyScope scope) { return new ParsedDependency { SourceRef = source, DependsOn = dependsOn, Scope = scope }; } }