// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. using Microsoft.Extensions.Logging.Abstractions; using Moq; using StellaOps.Scanner.Reachability; // Models are defined in the StellaOps.Scanner.Reachability namespace using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.Reachability.Tests; /// /// Unit tests for SubgraphExtractor. /// Tests the bounded BFS algorithm and subgraph extraction logic. /// public class SubgraphExtractorTests { private readonly Mock _graphStoreMock; private readonly Mock _entryPointResolverMock; private readonly Mock _vulnSurfaceServiceMock; private readonly SubgraphExtractor _extractor; private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken; public SubgraphExtractorTests() { _graphStoreMock = new Mock(); _entryPointResolverMock = new Mock(); _vulnSurfaceServiceMock = new Mock(); _extractor = new SubgraphExtractor( _graphStoreMock.Object, _entryPointResolverMock.Object, _vulnSurfaceServiceMock.Object, NullLogger.Instance ); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ResolveAsync_WithSinglePath_ReturnsCorrectSubgraph() { // Arrange var graphHash = "blake3:abc123"; var buildId = "gnu-build-id:test"; var componentRef = "pkg:maven/log4j@2.14.1"; var vulnId = "CVE-2021-44228"; var graph = CreateSimpleGraph(); _graphStoreMock .Setup(x => x.FetchGraphAsync(graphHash, It.IsAny())) .ReturnsAsync(graph); _entryPointResolverMock .Setup(x => x.ResolveAsync(graph, It.IsAny())) .ReturnsAsync(new List { new EntryPoint("main", "main()") }); _vulnSurfaceServiceMock .Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny())) .ReturnsAsync(new List { new AffectedSymbol("vulnerable", "vulnerable()", null) }); var request = new ReachabilityResolutionRequest( graphHash, buildId, componentRef, vulnId, "sha256:policy", ResolverOptions.Default); // Act var result = await _extractor.ResolveAsync(request, TestCancellationToken); // Assert Assert.NotNull(result); Assert.Equal(vulnId, result.VulnId); Assert.Equal(componentRef, result.ComponentRef); Assert.True(result.Nodes.Count > 0); Assert.True(result.Edges.Count > 0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ResolveAsync_NoReachablePath_ReturnsNull() { // Arrange var graphHash = "blake3:abc123"; var buildId = "gnu-build-id:test"; var componentRef = "pkg:maven/safe-lib@1.0.0"; var vulnId = "CVE-9999-99999"; var graph = CreateDisconnectedGraph(); _graphStoreMock .Setup(x => x.FetchGraphAsync(graphHash, It.IsAny())) .ReturnsAsync(graph); _entryPointResolverMock .Setup(x => x.ResolveAsync(graph, It.IsAny())) .ReturnsAsync(new List { new EntryPoint("main", "main()") }); _vulnSurfaceServiceMock .Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny())) .ReturnsAsync(new List { new AffectedSymbol("isolated", "isolated()", null) }); var request = new ReachabilityResolutionRequest( graphHash, buildId, componentRef, vulnId, "sha256:policy", ResolverOptions.Default); // Act var result = await _extractor.ResolveAsync(request, TestCancellationToken); // Assert Assert.Null(result); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ResolveAsync_DeterministicOrdering_ProducesSameHash() { // Arrange var graphHash = "blake3:abc123"; var buildId = "gnu-build-id:test"; var componentRef = "pkg:maven/log4j@2.14.1"; var vulnId = "CVE-2021-44228"; var graph = CreateSimpleGraph(); _graphStoreMock .Setup(x => x.FetchGraphAsync(graphHash, It.IsAny())) .ReturnsAsync(graph); _entryPointResolverMock .Setup(x => x.ResolveAsync(graph, It.IsAny())) .ReturnsAsync(new List { new EntryPoint("main", "main()") }); _vulnSurfaceServiceMock .Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny())) .ReturnsAsync(new List { new AffectedSymbol("vulnerable", "vulnerable()", null) }); var request = new ReachabilityResolutionRequest( graphHash, buildId, componentRef, vulnId, "sha256:policy", ResolverOptions.Default); // Act var result1 = await _extractor.ResolveAsync(request, TestCancellationToken); var result2 = await _extractor.ResolveAsync(request, TestCancellationToken); // Assert Assert.NotNull(result1); Assert.NotNull(result2); // Both should produce same node/edge ordering Assert.Equal( string.Join(",", result1.Nodes.Select(n => n.Symbol)), string.Join(",", result2.Nodes.Select(n => n.Symbol)) ); } private RichGraphV1 CreateSimpleGraph() { // Simple graph: main -> process -> vulnerable return new RichGraphV1( GraphHash: "blake3:abc123", ToolchainDigest: "sha256:tool123", Nodes: new List { new GraphNode("main", "main()", null), new GraphNode("process", "process()", null), new GraphNode("vulnerable", "vulnerable()", null) }, Edges: new List { new GraphEdge("main", "process", null, 0.95), new GraphEdge("process", "vulnerable", null, 0.90) } ); } private RichGraphV1 CreateDisconnectedGraph() { // Disconnected graph: main (isolated) and vulnerable (isolated) return new RichGraphV1( GraphHash: "blake3:abc123", ToolchainDigest: "sha256:tool123", Nodes: new List { new GraphNode("main", "main()", null), new GraphNode("isolated", "isolated()", null) }, Edges: new List() // No edges ); } }