partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,405 @@
// <copyright file="LineageGraphOptimizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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<LineageGraphOptimizer>.Instance,
_cache,
Options.Create(_options));
}
[Fact]
public void Optimize_WithEmptyGraph_ReturnsEmpty()
{
// Arrange
var request = new LineageOptimizationRequest
{
TenantId = Guid.NewGuid(),
CenterDigest = "sha256:center",
AllNodes = ImmutableArray<LineageNode>.Empty,
AllEdges = ImmutableArray<LineageEdge>.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<LineageNode>
{
new LineageNode("sha256:center", "center", "1.0.0", 10)
};
var edgesList = new List<LineageEdge>();
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<LineageLevel>();
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<LineageLevel>();
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<LineageEdge>.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<string, byte[]> _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<byte[]?> 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;
}
}
}

View File

@@ -0,0 +1,401 @@
// <copyright file="LineageStreamServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.SbomService.Lineage.Services;
using Xunit;
namespace StellaOps.SbomService.Lineage.Tests.Services;
public sealed class LineageStreamServiceTests : IDisposable
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly LineageStreamService _service;
public LineageStreamServiceTests()
{
_service = new LineageStreamService(
NullLogger<LineageStreamService>.Instance,
_timeProvider);
}
public void Dispose()
{
_service.Dispose();
}
[Fact]
public async Task PublishAsync_DeliversToSubscribers()
{
// Arrange
var tenantId = Guid.NewGuid();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var receivedEvents = new List<LineageUpdateEvent>();
var subscriptionTask = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
{
receivedEvents.Add(evt);
if (receivedEvents.Count >= 1)
{
break;
}
}
});
// Wait for subscription to be established
await Task.Delay(100);
// Act
await _service.NotifySbomAddedAsync(
tenantId,
"sha256:abc123",
null,
new SbomVersionSummary
{
Name = "test-app",
Version = "1.0.0",
ComponentCount = 10,
CreatedAt = _timeProvider.GetUtcNow()
});
// Assert
await subscriptionTask;
receivedEvents.Should().HaveCount(1);
receivedEvents[0].EventType.Should().Be(LineageEventType.SbomAdded);
receivedEvents[0].AffectedDigest.Should().Be("sha256:abc123");
}
[Fact]
public async Task SubscribeAsync_FiltersUnwatchedDigests()
{
// Arrange
var tenantId = Guid.NewGuid();
var watchDigests = new[] { "sha256:watched" };
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var receivedEvents = new List<LineageUpdateEvent>();
var subscriptionTask = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, watchDigests, cts.Token))
{
receivedEvents.Add(evt);
if (receivedEvents.Count >= 1)
{
break;
}
}
});
// Wait for subscription
await Task.Delay(100);
// Act - publish to unwatched digest (should be filtered)
await _service.NotifySbomAddedAsync(
tenantId,
"sha256:unwatched",
null,
new SbomVersionSummary
{
Name = "test",
Version = "1.0.0",
ComponentCount = 5,
CreatedAt = _timeProvider.GetUtcNow()
});
// Publish to watched digest (should be delivered)
await _service.NotifySbomAddedAsync(
tenantId,
"sha256:watched",
null,
new SbomVersionSummary
{
Name = "watched-app",
Version = "2.0.0",
ComponentCount = 15,
CreatedAt = _timeProvider.GetUtcNow()
});
// Assert
await subscriptionTask;
receivedEvents.Should().HaveCount(1);
receivedEvents[0].AffectedDigest.Should().Be("sha256:watched");
}
[Fact]
public async Task SubscribeAsync_ReceivesParentDigestUpdates()
{
// Arrange
var tenantId = Guid.NewGuid();
var watchDigests = new[] { "sha256:parent" };
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var receivedEvents = new List<LineageUpdateEvent>();
var subscriptionTask = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, watchDigests, cts.Token))
{
receivedEvents.Add(evt);
if (receivedEvents.Count >= 1)
{
break;
}
}
});
await Task.Delay(100);
// Act - publish with parent digest matching watch list
await _service.NotifySbomAddedAsync(
tenantId,
"sha256:child",
"sha256:parent",
new SbomVersionSummary
{
Name = "child-app",
Version = "1.0.0",
ComponentCount = 8,
CreatedAt = _timeProvider.GetUtcNow()
});
// Assert
await subscriptionTask;
receivedEvents.Should().HaveCount(1);
receivedEvents[0].ParentDigest.Should().Be("sha256:parent");
}
[Fact]
public async Task NotifyVexChangedAsync_PublishesCorrectEvent()
{
// Arrange
var tenantId = Guid.NewGuid();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var receivedEvents = new List<LineageUpdateEvent>();
var subscriptionTask = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
{
receivedEvents.Add(evt);
if (receivedEvents.Count >= 1)
{
break;
}
}
});
await Task.Delay(100);
// Act
await _service.NotifyVexChangedAsync(
tenantId,
"sha256:abc123",
new VexChangeData
{
Cve = "CVE-2024-1234",
FromStatus = "Affected",
ToStatus = "NotAffected",
Justification = "Component not in use"
});
// Assert
await subscriptionTask;
receivedEvents.Should().HaveCount(1);
receivedEvents[0].EventType.Should().Be(LineageEventType.VexChanged);
var data = receivedEvents[0].Data.Should().BeOfType<VexChangeData>().Subject;
data.Cve.Should().Be("CVE-2024-1234");
}
[Fact]
public async Task NotifyReachabilityUpdatedAsync_PublishesCorrectEvent()
{
// Arrange
var tenantId = Guid.NewGuid();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var receivedEvents = new List<LineageUpdateEvent>();
var subscriptionTask = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
{
receivedEvents.Add(evt);
if (receivedEvents.Count >= 1)
{
break;
}
}
});
await Task.Delay(100);
// Act
await _service.NotifyReachabilityUpdatedAsync(
tenantId,
"sha256:abc123",
new ReachabilityUpdateData
{
TotalPaths = 100,
ReachablePaths = 25,
UnreachablePaths = 75,
TopReachableCves = new[] { "CVE-2024-1234", "CVE-2024-5678" }
});
// Assert
await subscriptionTask;
receivedEvents.Should().HaveCount(1);
receivedEvents[0].EventType.Should().Be(LineageEventType.ReachabilityUpdated);
}
[Fact]
public async Task NotifyEdgeChangedAsync_PublishesCorrectEvent()
{
// Arrange
var tenantId = Guid.NewGuid();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var receivedEvents = new List<LineageUpdateEvent>();
var subscriptionTask = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
{
receivedEvents.Add(evt);
if (receivedEvents.Count >= 1)
{
break;
}
}
});
await Task.Delay(100);
// Act
await _service.NotifyEdgeChangedAsync(
tenantId,
"sha256:parent",
"sha256:child",
LineageEdgeChangeType.Added);
// Assert
await subscriptionTask;
receivedEvents.Should().HaveCount(1);
receivedEvents[0].EventType.Should().Be(LineageEventType.EdgeChanged);
var data = receivedEvents[0].Data.Should().BeOfType<LineageEdgeChangeData>().Subject;
data.FromDigest.Should().Be("sha256:parent");
data.ToDigest.Should().Be("sha256:child");
data.ChangeType.Should().Be(LineageEdgeChangeType.Added);
}
[Fact]
public async Task MultipleSubscribers_ReceiveSameEvent()
{
// Arrange
var tenantId = Guid.NewGuid();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var subscriber1Events = new List<LineageUpdateEvent>();
var subscriber2Events = new List<LineageUpdateEvent>();
var sub1Task = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
{
subscriber1Events.Add(evt);
if (subscriber1Events.Count >= 1) break;
}
});
var sub2Task = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
{
subscriber2Events.Add(evt);
if (subscriber2Events.Count >= 1) break;
}
});
await Task.Delay(100);
// Act
await _service.NotifySbomAddedAsync(
tenantId,
"sha256:shared",
null,
new SbomVersionSummary
{
Name = "shared-app",
Version = "1.0.0",
ComponentCount = 20,
CreatedAt = _timeProvider.GetUtcNow()
});
// Assert
await Task.WhenAll(sub1Task, sub2Task);
subscriber1Events.Should().HaveCount(1);
subscriber2Events.Should().HaveCount(1);
subscriber1Events[0].AffectedDigest.Should().Be(subscriber2Events[0].AffectedDigest);
}
[Fact]
public async Task DifferentTenants_DoNotReceiveEachOthersEvents()
{
// Arrange
var tenant1 = Guid.NewGuid();
var tenant2 = Guid.NewGuid();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var tenant1Events = new List<LineageUpdateEvent>();
var tenant2Events = new List<LineageUpdateEvent>();
var sub1Task = Task.Run(async () =>
{
await foreach (var evt in _service.SubscribeAsync(tenant1, ct: cts.Token))
{
tenant1Events.Add(evt);
if (tenant1Events.Count >= 1) break;
}
});
var sub2Task = Task.Run(async () =>
{
try
{
await foreach (var evt in _service.SubscribeAsync(tenant2, ct: cts.Token))
{
tenant2Events.Add(evt);
}
}
catch (OperationCanceledException)
{
// Expected
}
});
await Task.Delay(100);
// Act - publish only to tenant1
await _service.NotifySbomAddedAsync(
tenant1,
"sha256:tenant1only",
null,
new SbomVersionSummary
{
Name = "tenant1-app",
Version = "1.0.0",
ComponentCount = 10,
CreatedAt = _timeProvider.GetUtcNow()
});
await sub1Task;
cts.Cancel();
try { await sub2Task; } catch { }
// Assert
tenant1Events.Should().HaveCount(1);
tenant2Events.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,349 @@
// <copyright file="LineageStreamControllerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.SbomService.Controllers;
using StellaOps.SbomService.Lineage.Domain;
using StellaOps.SbomService.Lineage.Services;
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.SbomService.Tests.Lineage;
public sealed class LineageStreamControllerTests
{
private readonly FakeTimeProvider _timeProvider = new();
private readonly InMemoryLineageStreamService _streamService;
private readonly InMemoryLineageGraphOptimizer _optimizer;
private readonly InMemoryLineageGraphService _graphService;
private readonly LineageStreamController _controller;
public LineageStreamControllerTests()
{
_streamService = new InMemoryLineageStreamService(_timeProvider);
_optimizer = new InMemoryLineageGraphOptimizer();
_graphService = new InMemoryLineageGraphService();
_controller = new LineageStreamController(
_streamService,
_optimizer,
_graphService,
NullLogger<LineageStreamController>.Instance);
// Set up HttpContext for controller
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
}
[Fact]
public async Task GetOptimizedLineage_WithValidDigest_ReturnsOptimizedGraph()
{
// Arrange
var digest = "sha256:abc123";
_graphService.SetupGraph(digest, new LineageGraphResponse(
new LineageGraphDto(
Nodes: ImmutableArray.Create(
new LineageNodeDto(digest, "app", "1.0.0", 10),
new LineageNodeDto("sha256:child", "lib", "1.0.0", 5)),
Edges: ImmutableArray.Create(
new LineageEdgeDto(digest, "sha256:child"))),
Enrichment: null));
// Act
var result = await _controller.GetOptimizedLineage(digest, maxDepth: 3, pageSize: 50, pageNumber: 0);
// Assert
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
var graph = okResult.Value.Should().BeOfType<OptimizedLineageGraphDto>().Subject;
graph.CenterDigest.Should().Be(digest);
graph.Nodes.Should().HaveCountGreaterOrEqualTo(1);
}
[Fact]
public async Task GetOptimizedLineage_WithInvalidDepth_ReturnsBadRequest()
{
// Act
var result = await _controller.GetOptimizedLineage("sha256:abc123", maxDepth: 100);
// Assert
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().NotBeNull();
}
[Fact]
public async Task GetOptimizedLineage_EmptyDigest_ReturnsBadRequest()
{
// Act
var result = await _controller.GetOptimizedLineage("");
// Assert
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task GetOptimizedLineage_NotFound_ReturnsNotFound()
{
// Arrange - no graph setup
// Act
var result = await _controller.GetOptimizedLineage("sha256:nonexistent");
// Assert
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task GetMetadata_WithValidDigest_ReturnsMetadata()
{
// Arrange
var digest = "sha256:meta123";
_graphService.SetupGraph(digest, new LineageGraphResponse(
new LineageGraphDto(
Nodes: ImmutableArray.Create(
new LineageNodeDto(digest, "app", "1.0.0", 10)),
Edges: ImmutableArray<LineageEdgeDto>.Empty),
Enrichment: null));
// Act
var result = await _controller.GetMetadata(digest);
// Assert
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
var metadata = okResult.Value.Should().BeOfType<LineageGraphMetadataDto>().Subject;
metadata.CenterDigest.Should().Be(digest);
metadata.TotalNodes.Should().Be(1);
}
[Fact]
public async Task GetMetadata_NotFound_ReturnsNotFound()
{
// Act
var result = await _controller.GetMetadata("sha256:missing");
// Assert
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task InvalidateCache_ReturnsNoContent()
{
// Act
var result = await _controller.InvalidateCache("sha256:abc123");
// Assert
result.Should().BeOfType<NoContentResult>();
}
[Fact]
public async Task GetOptimizedLineage_WithSearchTerm_FiltersNodes()
{
// Arrange
var digest = "sha256:center";
_graphService.SetupGraph(digest, new LineageGraphResponse(
new LineageGraphDto(
Nodes: ImmutableArray.Create(
new LineageNodeDto(digest, "center-app", "1.0.0", 10),
new LineageNodeDto("sha256:logging", "logging-lib", "1.0.0", 5),
new LineageNodeDto("sha256:database", "database-lib", "1.0.0", 8)),
Edges: ImmutableArray.Create(
new LineageEdgeDto(digest, "sha256:logging"),
new LineageEdgeDto(digest, "sha256:database"))),
Enrichment: null));
// Act
var result = await _controller.GetOptimizedLineage(digest, searchTerm: "log");
// Assert
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
var graph = okResult.Value.Should().BeOfType<OptimizedLineageGraphDto>().Subject;
// The optimizer filters, so we verify it was called with the search term
_optimizer.LastRequest.Should().NotBeNull();
_optimizer.LastRequest!.SearchTerm.Should().Be("log");
}
[Fact]
public async Task GetOptimizedLineage_WithPagination_ReturnsPagedResults()
{
// Arrange
var digest = "sha256:center";
var nodes = new List<LineageNodeDto>
{
new(digest, "center", "1.0.0", 10)
};
var edges = new List<LineageEdgeDto>();
for (int i = 0; i < 20; i++)
{
var childDigest = $"sha256:child{i:D2}";
nodes.Add(new LineageNodeDto(childDigest, $"child-{i}", "1.0.0", i + 1));
edges.Add(new LineageEdgeDto(digest, childDigest));
}
_graphService.SetupGraph(digest, new LineageGraphResponse(
new LineageGraphDto(
Nodes: nodes.ToImmutableArray(),
Edges: edges.ToImmutableArray()),
Enrichment: null));
// Act
var result = await _controller.GetOptimizedLineage(digest, pageSize: 5, pageNumber: 0);
// Assert
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
var graph = okResult.Value.Should().BeOfType<OptimizedLineageGraphDto>().Subject;
graph.PageSize.Should().Be(5);
graph.PageNumber.Should().Be(0);
}
// Test helper implementations
private sealed class InMemoryLineageStreamService : ILineageStreamService
{
private readonly TimeProvider _timeProvider;
public InMemoryLineageStreamService(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public void Dispose() { }
public async IAsyncEnumerable<LineageUpdateEvent> SubscribeAsync(
Guid tenantId,
IReadOnlyList<string>? watchDigests = null,
CancellationToken ct = default)
{
await Task.CompletedTask;
yield break;
}
public Task PublishAsync(Guid tenantId, LineageUpdateEvent evt, CancellationToken ct = default)
=> Task.CompletedTask;
public Task NotifySbomAddedAsync(Guid tenantId, string artifactDigest, string? parentDigest,
SbomVersionSummary summary, CancellationToken ct = default)
=> Task.CompletedTask;
public Task NotifyVexChangedAsync(Guid tenantId, string artifactDigest, VexChangeData change,
CancellationToken ct = default)
=> Task.CompletedTask;
public Task NotifyReachabilityUpdatedAsync(Guid tenantId, string artifactDigest, ReachabilityUpdateData update,
CancellationToken ct = default)
=> Task.CompletedTask;
public Task NotifyEdgeChangedAsync(Guid tenantId, string fromDigest, string toDigest,
LineageEdgeChangeType changeType, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class InMemoryLineageGraphOptimizer : ILineageGraphOptimizer
{
public LineageOptimizationRequest? LastRequest { get; private set; }
public OptimizedLineageGraph Optimize(LineageOptimizationRequest request)
{
LastRequest = request;
return new OptimizedLineageGraph
{
Nodes = request.AllNodes,
Edges = request.AllEdges,
BoundaryNodes = ImmutableArray<BoundaryNodeInfo>.Empty,
TotalNodes = request.AllNodes.Length,
HasMorePages = false
};
}
public async IAsyncEnumerable<LineageLevel> TraverseLevelsAsync(
string centerDigest,
ImmutableArray<LineageNode> nodes,
ImmutableArray<LineageEdge> edges,
TraversalDirection direction,
int maxDepth = 10,
CancellationToken ct = default)
{
await Task.CompletedTask;
yield return new LineageLevel(0, nodes, true);
}
public Task<LineageGraphMetadata> GetOrComputeMetadataAsync(
Guid tenantId,
string centerDigest,
ImmutableArray<LineageNode> nodes,
ImmutableArray<LineageEdge> edges,
CancellationToken ct = default)
{
return Task.FromResult(new LineageGraphMetadata(
TotalNodes: nodes.Length,
TotalEdges: edges.Length,
MaxDepth: 1,
ComputedAt: DateTimeOffset.UtcNow));
}
public Task InvalidateCacheAsync(Guid tenantId, string centerDigest, CancellationToken ct = default)
=> Task.CompletedTask;
}
private sealed class InMemoryLineageGraphService : ILineageGraphService
{
private readonly Dictionary<string, LineageGraphResponse> _graphs = new();
public void SetupGraph(string digest, LineageGraphResponse response)
{
_graphs[digest] = response;
}
public ValueTask<LineageGraphResponse> GetLineageAsync(
string artifactDigest,
Guid tenantId,
LineageQueryOptions options,
CancellationToken ct = default)
{
if (_graphs.TryGetValue(artifactDigest, out var response))
return ValueTask.FromResult(response);
return ValueTask.FromResult(new LineageGraphResponse(
new LineageGraphDto(ImmutableArray<LineageNodeDto>.Empty, ImmutableArray<LineageEdgeDto>.Empty),
null));
}
public ValueTask<LineageDiffResponse> GetDiffAsync(
string fromDigest,
string toDigest,
Guid tenantId,
CancellationToken ct = default)
{
return ValueTask.FromResult(new LineageDiffResponse(
ImmutableArray<LineageChangeSummary>.Empty,
ImmutableArray<LineageChangeSummary>.Empty,
ImmutableArray<LineageChangeSummary>.Empty));
}
public ValueTask<ExportResult> ExportEvidencePackAsync(
ExportRequest request,
Guid tenantId,
CancellationToken ct = default)
{
return ValueTask.FromResult(new ExportResult("https://example.com/pack.zip", 1024));
}
}
}
// Placeholder types to match interface expectations
file record LineageNodeDto(string Digest, string Name, string Version, int ComponentCount);
file record LineageEdgeDto(string FromDigest, string ToDigest);
file record LineageGraphDto(ImmutableArray<LineageNodeDto> Nodes, ImmutableArray<LineageEdgeDto> Edges);
file record LineageGraphResponse(LineageGraphDto Graph, object? Enrichment);
file record LineageDiffResponse(
ImmutableArray<LineageChangeSummary> Added,
ImmutableArray<LineageChangeSummary> Removed,
ImmutableArray<LineageChangeSummary> Modified);
file record LineageChangeSummary(string Digest, string Name);
file record ExportRequest(string ArtifactDigest, int MaxDepth);
file record ExportResult(string DownloadUrl, long SizeBytes);
file record LineageQueryOptions(int MaxDepth, bool IncludeVerdicts, bool IncludeBadges);