Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/SubgraphExtractorTests.cs
2026-01-08 20:46:43 +02:00

202 lines
6.9 KiB
C#

// 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;
/// <summary>
/// Unit tests for SubgraphExtractor.
/// Tests the bounded BFS algorithm and subgraph extraction logic.
/// </summary>
public class SubgraphExtractorTests
{
private readonly Mock<IRichGraphStore> _graphStoreMock;
private readonly Mock<IEntryPointResolver> _entryPointResolverMock;
private readonly Mock<IVulnSurfaceService> _vulnSurfaceServiceMock;
private readonly SubgraphExtractor _extractor;
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
public SubgraphExtractorTests()
{
_graphStoreMock = new Mock<IRichGraphStore>();
_entryPointResolverMock = new Mock<IEntryPointResolver>();
_vulnSurfaceServiceMock = new Mock<IVulnSurfaceService>();
_extractor = new SubgraphExtractor(
_graphStoreMock.Object,
_entryPointResolverMock.Object,
_vulnSurfaceServiceMock.Object,
NullLogger<SubgraphExtractor>.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<CancellationToken>()))
.ReturnsAsync(graph);
_entryPointResolverMock
.Setup(x => x.ResolveAsync(graph, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EntryPoint>
{
new EntryPoint("main", "main()")
});
_vulnSurfaceServiceMock
.Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<AffectedSymbol>
{
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<CancellationToken>()))
.ReturnsAsync(graph);
_entryPointResolverMock
.Setup(x => x.ResolveAsync(graph, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EntryPoint>
{
new EntryPoint("main", "main()")
});
_vulnSurfaceServiceMock
.Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<AffectedSymbol>
{
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<CancellationToken>()))
.ReturnsAsync(graph);
_entryPointResolverMock
.Setup(x => x.ResolveAsync(graph, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EntryPoint>
{
new EntryPoint("main", "main()")
});
_vulnSurfaceServiceMock
.Setup(x => x.GetAffectedSymbolsAsync(vulnId, componentRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<AffectedSymbol>
{
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<GraphNode>
{
new GraphNode("main", "main()", null),
new GraphNode("process", "process()", null),
new GraphNode("vulnerable", "vulnerable()", null)
},
Edges: new List<GraphEdge>
{
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<GraphNode>
{
new GraphNode("main", "main()", null),
new GraphNode("isolated", "isolated()", null)
},
Edges: new List<GraphEdge>() // No edges
);
}
}