// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using System.Collections.Immutable; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.SbomService.Lineage.Services; using Xunit; namespace StellaOps.SbomService.Lineage.Tests.Services; public sealed class LineageGraphOptimizerTests { private readonly InMemoryDistributedCache _cache = new(); private readonly LineageGraphOptimizer _optimizer; private readonly LineageGraphOptimizerOptions _options = new() { MaxNodes = 100, DefaultDepth = 3, CacheDuration = TimeSpan.FromMinutes(10) }; public LineageGraphOptimizerTests() { _optimizer = new LineageGraphOptimizer( NullLogger.Instance, _cache, Options.Create(_options)); } [Fact] public void Optimize_WithEmptyGraph_ReturnsEmpty() { // Arrange var request = new LineageOptimizationRequest { TenantId = Guid.NewGuid(), CenterDigest = "sha256:center", AllNodes = ImmutableArray.Empty, AllEdges = ImmutableArray.Empty, MaxDepth = 3 }; // Act var result = _optimizer.Optimize(request); // Assert result.Nodes.Should().BeEmpty(); result.Edges.Should().BeEmpty(); result.BoundaryNodes.Should().BeEmpty(); } [Fact] public void Optimize_PrunesByDepth() { // Arrange - Create a chain: center -> child1 -> child2 -> child3 var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center", "1.0.0", 10), new LineageNode("sha256:child1", "child1", "1.0.0", 5), new LineageNode("sha256:child2", "child2", "1.0.0", 8), new LineageNode("sha256:child3", "child3", "1.0.0", 3)); var edges = ImmutableArray.Create( new LineageEdge("sha256:center", "sha256:child1"), new LineageEdge("sha256:child1", "sha256:child2"), new LineageEdge("sha256:child2", "sha256:child3")); var request = new LineageOptimizationRequest { TenantId = Guid.NewGuid(), CenterDigest = "sha256:center", AllNodes = nodes, AllEdges = edges, MaxDepth = 2 // Should include center, child1, child2 but mark child2 as boundary }; // Act var result = _optimizer.Optimize(request); // Assert - child3 should be pruned result.Nodes.Should().HaveCount(3); result.Nodes.Should().Contain(n => n.Digest == "sha256:center"); result.Nodes.Should().Contain(n => n.Digest == "sha256:child1"); result.Nodes.Should().Contain(n => n.Digest == "sha256:child2"); result.Nodes.Should().NotContain(n => n.Digest == "sha256:child3"); // child2 should be marked as boundary result.BoundaryNodes.Should().ContainSingle(); result.BoundaryNodes[0].Digest.Should().Be("sha256:child2"); } [Fact] public void Optimize_FiltersNodesBySearchTerm() { // Arrange var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center-app", "1.0.0", 10), new LineageNode("sha256:child1", "logging-lib", "1.0.0", 5), new LineageNode("sha256:child2", "database-lib", "1.0.0", 8)); var edges = ImmutableArray.Create( new LineageEdge("sha256:center", "sha256:child1"), new LineageEdge("sha256:center", "sha256:child2")); var request = new LineageOptimizationRequest { TenantId = Guid.NewGuid(), CenterDigest = "sha256:center", AllNodes = nodes, AllEdges = edges, SearchTerm = "log", MaxDepth = 10 }; // Act var result = _optimizer.Optimize(request); // Assert - Only center (always included) and logging-lib (matches search) result.Nodes.Should().HaveCount(2); result.Nodes.Should().Contain(n => n.Name == "center-app"); result.Nodes.Should().Contain(n => n.Name == "logging-lib"); result.Nodes.Should().NotContain(n => n.Name == "database-lib"); } [Fact] public void Optimize_AppliesPagination() { // Arrange - Create 10 children var nodesList = new List { new LineageNode("sha256:center", "center", "1.0.0", 10) }; var edgesList = new List(); for (int i = 0; i < 10; i++) { var childDigest = $"sha256:child{i:D2}"; nodesList.Add(new LineageNode(childDigest, $"child-{i}", "1.0.0", i + 1)); edgesList.Add(new LineageEdge("sha256:center", childDigest)); } var request = new LineageOptimizationRequest { TenantId = Guid.NewGuid(), CenterDigest = "sha256:center", AllNodes = nodesList.ToImmutableArray(), AllEdges = edgesList.ToImmutableArray(), MaxDepth = 10, PageSize = 5, PageNumber = 0 }; // Act var result = _optimizer.Optimize(request); // Assert - Should have 6 nodes (center + 5 children) result.Nodes.Should().HaveCount(6); result.TotalNodes.Should().Be(11); result.HasMorePages.Should().BeTrue(); } [Fact] public async Task TraverseLevelsAsync_ReturnsLevelsInOrder() { // Arrange var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center", "1.0.0", 10), new LineageNode("sha256:level1a", "level1a", "1.0.0", 5), new LineageNode("sha256:level1b", "level1b", "1.0.0", 5), new LineageNode("sha256:level2", "level2", "1.0.0", 3)); var edges = ImmutableArray.Create( new LineageEdge("sha256:center", "sha256:level1a"), new LineageEdge("sha256:center", "sha256:level1b"), new LineageEdge("sha256:level1a", "sha256:level2")); // Act var levels = new List(); await foreach (var level in _optimizer.TraverseLevelsAsync( "sha256:center", nodes, edges, TraversalDirection.Children, maxDepth: 5)) { levels.Add(level); } // Assert levels.Should().HaveCount(3); levels[0].Depth.Should().Be(0); levels[0].Nodes.Should().ContainSingle(n => n.Digest == "sha256:center"); levels[1].Depth.Should().Be(1); levels[1].Nodes.Should().HaveCount(2); levels[2].Depth.Should().Be(2); levels[2].Nodes.Should().ContainSingle(n => n.Digest == "sha256:level2"); } [Fact] public async Task TraverseLevelsAsync_Parents_TraversesUpward() { // Arrange var nodes = ImmutableArray.Create( new LineageNode("sha256:root", "root", "1.0.0", 10), new LineageNode("sha256:middle", "middle", "1.0.0", 5), new LineageNode("sha256:leaf", "leaf", "1.0.0", 3)); var edges = ImmutableArray.Create( new LineageEdge("sha256:root", "sha256:middle"), new LineageEdge("sha256:middle", "sha256:leaf")); // Act - traverse from leaf upward var levels = new List(); await foreach (var level in _optimizer.TraverseLevelsAsync( "sha256:leaf", nodes, edges, TraversalDirection.Parents, maxDepth: 5)) { levels.Add(level); } // Assert levels.Should().HaveCount(3); levels[0].Nodes.Should().ContainSingle(n => n.Digest == "sha256:leaf"); levels[1].Nodes.Should().ContainSingle(n => n.Digest == "sha256:middle"); levels[2].Nodes.Should().ContainSingle(n => n.Digest == "sha256:root"); } [Fact] public async Task GetOrComputeMetadataAsync_CachesResult() { // Arrange var tenantId = Guid.NewGuid(); var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center", "1.0.0", 10), new LineageNode("sha256:child", "child", "1.0.0", 5)); var edges = ImmutableArray.Create( new LineageEdge("sha256:center", "sha256:child")); // Act - first call computes var metadata1 = await _optimizer.GetOrComputeMetadataAsync( tenantId, "sha256:center", nodes, edges); // Second call should use cache var metadata2 = await _optimizer.GetOrComputeMetadataAsync( tenantId, "sha256:center", nodes, edges); // Assert metadata1.TotalNodes.Should().Be(2); metadata1.TotalEdges.Should().Be(1); metadata2.Should().BeEquivalentTo(metadata1); // Verify cache was used _cache.GetCallCount.Should().BeGreaterThan(1); } [Fact] public async Task InvalidateCacheAsync_RemovesCachedMetadata() { // Arrange var tenantId = Guid.NewGuid(); var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center", "1.0.0", 10)); var edges = ImmutableArray.Empty; // Populate cache await _optimizer.GetOrComputeMetadataAsync( tenantId, "sha256:center", nodes, edges); // Act await _optimizer.InvalidateCacheAsync(tenantId, "sha256:center"); // Assert - cache should be empty for this key _cache.RemoveCallCount.Should().BeGreaterThan(0); } [Fact] public void Optimize_DetectsBoundaryNodesWithHiddenChildren() { // Arrange - Complex graph with deep children var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center", "1.0.0", 10), new LineageNode("sha256:child1", "child1", "1.0.0", 5), new LineageNode("sha256:grandchild", "grandchild", "1.0.0", 3), new LineageNode("sha256:greatgrand", "greatgrand", "1.0.0", 2)); var edges = ImmutableArray.Create( new LineageEdge("sha256:center", "sha256:child1"), new LineageEdge("sha256:child1", "sha256:grandchild"), new LineageEdge("sha256:grandchild", "sha256:greatgrand")); var request = new LineageOptimizationRequest { TenantId = Guid.NewGuid(), CenterDigest = "sha256:center", AllNodes = nodes, AllEdges = edges, MaxDepth = 2 }; // Act var result = _optimizer.Optimize(request); // Assert - grandchild is boundary because greatgrand is hidden result.BoundaryNodes.Should().ContainSingle(); result.BoundaryNodes[0].Digest.Should().Be("sha256:grandchild"); result.BoundaryNodes[0].HiddenChildrenCount.Should().Be(1); } [Fact] public void Optimize_HandlesDisconnectedNodes() { // Arrange - Nodes not connected to center var nodes = ImmutableArray.Create( new LineageNode("sha256:center", "center", "1.0.0", 10), new LineageNode("sha256:connected", "connected", "1.0.0", 5), new LineageNode("sha256:disconnected", "disconnected", "1.0.0", 3)); var edges = ImmutableArray.Create( new LineageEdge("sha256:center", "sha256:connected")); var request = new LineageOptimizationRequest { TenantId = Guid.NewGuid(), CenterDigest = "sha256:center", AllNodes = nodes, AllEdges = edges, MaxDepth = 10 }; // Act var result = _optimizer.Optimize(request); // Assert - disconnected node should not appear result.Nodes.Should().HaveCount(2); result.Nodes.Should().NotContain(n => n.Digest == "sha256:disconnected"); } private sealed class InMemoryDistributedCache : IDistributedCache { private readonly Dictionary _cache = new(); public int GetCallCount { get; private set; } public int SetCallCount { get; private set; } public int RemoveCallCount { get; private set; } public byte[]? Get(string key) { GetCallCount++; return _cache.TryGetValue(key, out var value) ? value : null; } public Task GetAsync(string key, CancellationToken token = default) { GetCallCount++; return Task.FromResult(_cache.TryGetValue(key, out var value) ? value : null); } public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { SetCallCount++; _cache[key] = value; } public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) { SetCallCount++; _cache[key] = value; return Task.CompletedTask; } public void Refresh(string key) { } public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask; public void Remove(string key) { RemoveCallCount++; _cache.Remove(key); } public Task RemoveAsync(string key, CancellationToken token = default) { RemoveCallCount++; _cache.Remove(key); return Task.CompletedTask; } } }