// 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
);
}
}