work work hard work
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Node;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
public class BenchmarkIntegrationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("unsafe-eval", true)]
|
||||
[InlineData("guarded-eval", false)]
|
||||
public async Task NodeTraceExtractor_AlignsWithBenchmarkReachability(string caseName, bool expectSinkReachable)
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
var caseDir = Path.Combine(repoRoot, "bench", "reachability-benchmark", "cases", "js", caseName);
|
||||
|
||||
var extractor = new NodeCallGraphExtractor();
|
||||
var snapshot = await extractor.ExtractAsync(new CallGraphExtractionRequest(
|
||||
ScanId: $"bench-{caseName}",
|
||||
Language: "node",
|
||||
TargetPath: caseDir));
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
Assert.Equal(expectSinkReachable, result.ReachableSinkIds.Length > 0);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(directory.FullName, "bench", "reachability-benchmark")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to locate repository root for benchmark integration tests.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Scanner.CallGraph.Caching;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
public class CircuitBreakerStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordFailure_TripsOpen_AfterThreshold()
|
||||
{
|
||||
var config = new CircuitBreakerConfig
|
||||
{
|
||||
FailureThreshold = 2,
|
||||
TimeoutSeconds = 60,
|
||||
HalfOpenTimeout = 10
|
||||
};
|
||||
|
||||
var cb = new CircuitBreakerState(config);
|
||||
Assert.Equal(CircuitState.Closed, cb.State);
|
||||
|
||||
cb.RecordFailure();
|
||||
Assert.Equal(CircuitState.Closed, cb.State);
|
||||
|
||||
cb.RecordFailure();
|
||||
Assert.Equal(CircuitState.Open, cb.State);
|
||||
Assert.True(cb.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSuccess_ResetsToClosed()
|
||||
{
|
||||
var config = new CircuitBreakerConfig { FailureThreshold = 1, TimeoutSeconds = 60, HalfOpenTimeout = 10 };
|
||||
var cb = new CircuitBreakerState(config);
|
||||
cb.RecordFailure();
|
||||
Assert.True(cb.IsOpen);
|
||||
|
||||
cb.RecordSuccess();
|
||||
Assert.Equal(CircuitState.Closed, cb.State);
|
||||
Assert.False(cb.IsOpen);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.DotNet;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
public class DotNetCallGraphExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExtractAsync_SimpleProject_ProducesEntrypointAndSink()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var csprojPath = Path.Combine(temp.Path, "App.csproj");
|
||||
await File.WriteAllTextAsync(csprojPath, """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(temp.Path, "Program.cs"), """
|
||||
using System;
|
||||
|
||||
public sealed class HttpGetAttribute : Attribute { }
|
||||
|
||||
namespace System.Diagnostics
|
||||
{
|
||||
public static class Process
|
||||
{
|
||||
public static void Start(string cmd) { }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FooController
|
||||
{
|
||||
[HttpGet]
|
||||
public void Get()
|
||||
{
|
||||
Helper();
|
||||
}
|
||||
|
||||
private void Helper()
|
||||
{
|
||||
System.Diagnostics.Process.Start("cmd.exe");
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var fixedTime = DateTimeOffset.Parse("2025-12-17T00:00:00Z");
|
||||
var extractor = new DotNetCallGraphExtractor(new FixedTimeProvider(fixedTime));
|
||||
|
||||
var snapshot = await extractor.ExtractAsync(new CallGraphExtractionRequest(
|
||||
ScanId: "scan-001",
|
||||
Language: "dotnet",
|
||||
TargetPath: csprojPath));
|
||||
|
||||
Assert.Equal("scan-001", snapshot.ScanId);
|
||||
Assert.Equal("dotnet", snapshot.Language);
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot.GraphDigest));
|
||||
Assert.NotEmpty(snapshot.Nodes);
|
||||
Assert.NotEmpty(snapshot.Edges);
|
||||
|
||||
Assert.Contains(snapshot.Nodes, n => n.IsEntrypoint && n.EntrypointType == EntrypointType.HttpHandler);
|
||||
Assert.Contains(snapshot.Nodes, n => n.IsSink);
|
||||
Assert.NotEmpty(snapshot.SinkIds);
|
||||
Assert.NotEmpty(snapshot.EntrypointIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAsync_IsDeterministic_ForSameInputs()
|
||||
{
|
||||
await using var temp = await TempDirectory.CreateAsync();
|
||||
|
||||
var csprojPath = Path.Combine(temp.Path, "App.csproj");
|
||||
await File.WriteAllTextAsync(csprojPath, """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""");
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(temp.Path, "Program.cs"), """
|
||||
public static class Program
|
||||
{
|
||||
public static void Main()
|
||||
{
|
||||
A();
|
||||
}
|
||||
|
||||
private static void A()
|
||||
{
|
||||
B();
|
||||
}
|
||||
|
||||
private static void B()
|
||||
{
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var extractor = new DotNetCallGraphExtractor();
|
||||
var request = new CallGraphExtractionRequest("scan-001", "dotnet", csprojPath);
|
||||
|
||||
var first = await extractor.ExtractAsync(request);
|
||||
var second = await extractor.ExtractAsync(request);
|
||||
|
||||
Assert.Equal(first.GraphDigest, second.GraphDigest);
|
||||
Assert.Equal(first.Nodes.Select(n => n.NodeId), second.Nodes.Select(n => n.NodeId));
|
||||
Assert.Equal(first.Edges.Select(e => (e.SourceId, e.TargetId, e.CallKind)), second.Edges.Select(e => (e.SourceId, e.TargetId, e.CallKind)));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _instant;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset instant)
|
||||
{
|
||||
_instant = instant;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _instant;
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IAsyncDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
private TempDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public static Task<TempDirectory> CreateAsync()
|
||||
{
|
||||
var root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stella_callgraph_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
return Task.FromResult(new TempDirectory(root));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort cleanup
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
public class ReachabilityAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Analyze_WhenSinkReachable_ReturnsShortestPath()
|
||||
{
|
||||
var entry = CallGraphNodeIds.Compute("dotnet:test:entry");
|
||||
var mid = CallGraphNodeIds.Compute("dotnet:test:mid");
|
||||
var sink = CallGraphNodeIds.Compute("dotnet:test:sink");
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:placeholder",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "file.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid, "Mid", "file.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink, "Sink", "file.cs", 3, "System", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, mid, CallKind.Direct),
|
||||
new CallGraphEdge(mid, sink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [sink]);
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
Assert.Contains(sink, result.ReachableSinkIds);
|
||||
Assert.Single(result.Paths);
|
||||
Assert.Equal(entry, result.Paths[0].EntrypointId);
|
||||
Assert.Equal(sink, result.Paths[0].SinkId);
|
||||
Assert.Equal(ImmutableArray.Create(entry, mid, sink), result.Paths[0].NodeIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_WhenNoEntrypoints_ReturnsEmpty()
|
||||
{
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:placeholder",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty);
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
Assert.Empty(result.ReachableNodeIds);
|
||||
Assert.Empty(result.ReachableSinkIds);
|
||||
Assert.Empty(result.Paths);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.ResultDigest));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Messaging.Testing\\StellaOps.Messaging.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging.Testing.Fixtures;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Caching;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
[Collection(nameof(ValkeyFixtureCollection))]
|
||||
public class ValkeyCallGraphCacheServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ValkeyFixture _fixture;
|
||||
private ValkeyCallGraphCacheService _cache = null!;
|
||||
|
||||
public ValkeyCallGraphCacheServiceTests(ValkeyFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
var options = Options.Create(new CallGraphCacheConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
KeyPrefix = "test:callgraph:",
|
||||
TtlSeconds = 60,
|
||||
EnableGzip = true,
|
||||
CircuitBreaker = new CircuitBreakerConfig { FailureThreshold = 3, TimeoutSeconds = 30, HalfOpenTimeout = 10 }
|
||||
});
|
||||
|
||||
_cache = new ValkeyCallGraphCacheService(options, NullLogger<ValkeyCallGraphCacheService>.Instance);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cache.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetThenGet_CallGraph_RoundTrips()
|
||||
{
|
||||
var nodeId = CallGraphNodeIds.Compute("dotnet:test:entry");
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-cache-1",
|
||||
GraphDigest: "sha256:cg",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: [new CallGraphNode(nodeId, "Entry", "file.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null)],
|
||||
Edges: [],
|
||||
EntrypointIds: [nodeId],
|
||||
SinkIds: []);
|
||||
|
||||
await _cache.SetCallGraphAsync(snapshot);
|
||||
var loaded = await _cache.TryGetCallGraphAsync("scan-cache-1", "dotnet");
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(snapshot.ScanId, loaded!.ScanId);
|
||||
Assert.Equal(snapshot.Language, loaded.Language);
|
||||
Assert.Equal(snapshot.GraphDigest, loaded.GraphDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetThenGet_ReachabilityResult_RoundTrips()
|
||||
{
|
||||
var result = new ReachabilityAnalysisResult(
|
||||
ScanId: "scan-cache-2",
|
||||
GraphDigest: "sha256:cg",
|
||||
Language: "dotnet",
|
||||
ComputedAt: DateTimeOffset.UtcNow,
|
||||
ReachableNodeIds: [],
|
||||
ReachableSinkIds: [],
|
||||
Paths: [],
|
||||
ResultDigest: "sha256:r");
|
||||
|
||||
await _cache.SetReachabilityResultAsync(result);
|
||||
var loaded = await _cache.TryGetReachabilityResultAsync("scan-cache-2", "dotnet");
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(result.ResultDigest, loaded!.ResultDigest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ClassificationChangeTracker" />.
|
||||
/// </summary>
|
||||
public sealed class ClassificationChangeTrackerTests
|
||||
{
|
||||
private readonly FakeClassificationHistoryRepository _repository;
|
||||
private readonly ClassificationChangeTracker _tracker;
|
||||
|
||||
public ClassificationChangeTrackerTests()
|
||||
{
|
||||
_repository = new FakeClassificationHistoryRepository();
|
||||
_tracker = new ClassificationChangeTracker(
|
||||
_repository,
|
||||
NullLogger<ClassificationChangeTracker>.Instance,
|
||||
new FakeTimeProvider(DateTimeOffset.Parse("2025-12-17T00:00:00Z")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangeAsync_ActualChange_InsertsToRepository()
|
||||
{
|
||||
var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
|
||||
|
||||
await _tracker.TrackChangeAsync(change);
|
||||
|
||||
Assert.Single(_repository.InsertedChanges);
|
||||
Assert.Same(change, _repository.InsertedChanges[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangeAsync_NoOpChange_SkipsInsert()
|
||||
{
|
||||
var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected);
|
||||
|
||||
await _tracker.TrackChangeAsync(change);
|
||||
|
||||
Assert.Empty(_repository.InsertedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangesAsync_FiltersNoOpChanges()
|
||||
{
|
||||
var changes = new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected),
|
||||
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected), // No-op
|
||||
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed),
|
||||
};
|
||||
|
||||
await _tracker.TrackChangesAsync(changes);
|
||||
|
||||
Assert.Single(_repository.InsertedBatches);
|
||||
Assert.Equal(2, _repository.InsertedBatches[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangesAsync_EmptyAfterFilter_DoesNotInsert()
|
||||
{
|
||||
var changes = new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected),
|
||||
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Unknown),
|
||||
};
|
||||
|
||||
await _tracker.TrackChangesAsync(changes);
|
||||
|
||||
Assert.Empty(_repository.InsertedBatches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_UnknownToAffected_ReturnsTrue()
|
||||
{
|
||||
var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
|
||||
Assert.True(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_UnaffectedToAffected_ReturnsTrue()
|
||||
{
|
||||
var change = CreateChange(ClassificationStatus.Unaffected, ClassificationStatus.Affected);
|
||||
Assert.True(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_AffectedToFixed_ReturnsFalse()
|
||||
{
|
||||
var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed);
|
||||
Assert.False(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_NewToAffected_ReturnsFalse()
|
||||
{
|
||||
var change = CreateChange(ClassificationStatus.New, ClassificationStatus.Affected);
|
||||
Assert.False(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_NewFinding_RecordsAsNewStatus()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var artifact = "sha256:abc123";
|
||||
var prevExecId = Guid.NewGuid();
|
||||
var currExecId = Guid.NewGuid();
|
||||
|
||||
_repository.SetExecutionChanges(tenantId, prevExecId, Array.Empty<ClassificationChange>());
|
||||
_repository.SetExecutionChanges(tenantId, currExecId, new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.New, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
|
||||
});
|
||||
|
||||
var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId);
|
||||
|
||||
Assert.Single(delta);
|
||||
Assert.Equal(ClassificationStatus.New, delta[0].PreviousStatus);
|
||||
Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_StatusChange_RecordsDelta()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var artifact = "sha256:abc123";
|
||||
var prevExecId = Guid.NewGuid();
|
||||
var currExecId = Guid.NewGuid();
|
||||
|
||||
_repository.SetExecutionChanges(tenantId, prevExecId, new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.New, ClassificationStatus.Unknown, artifact, "CVE-2024-0001"),
|
||||
});
|
||||
_repository.SetExecutionChanges(tenantId, currExecId, new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
|
||||
});
|
||||
|
||||
var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId);
|
||||
|
||||
Assert.Single(delta);
|
||||
Assert.Equal(ClassificationStatus.Unknown, delta[0].PreviousStatus);
|
||||
Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus);
|
||||
}
|
||||
|
||||
private static ClassificationChange CreateChange(
|
||||
ClassificationStatus previous,
|
||||
ClassificationStatus next,
|
||||
string artifact = "sha256:test",
|
||||
string vulnId = "CVE-2024-0001")
|
||||
=> new()
|
||||
{
|
||||
ArtifactDigest = artifact,
|
||||
VulnId = vulnId,
|
||||
PackagePurl = "pkg:npm/test@1.0.0",
|
||||
TenantId = Guid.NewGuid(),
|
||||
ManifestId = Guid.NewGuid(),
|
||||
ExecutionId = Guid.NewGuid(),
|
||||
PreviousStatus = previous,
|
||||
NewStatus = next,
|
||||
Cause = DriftCause.FeedDelta,
|
||||
};
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
}
|
||||
|
||||
private sealed class FakeClassificationHistoryRepository : IClassificationHistoryRepository
|
||||
{
|
||||
private readonly Dictionary<(Guid tenantId, Guid executionId), IReadOnlyList<ClassificationChange>> _byExecution = new();
|
||||
|
||||
public List<ClassificationChange> InsertedChanges { get; } = new();
|
||||
public List<List<ClassificationChange>> InsertedBatches { get; } = new();
|
||||
|
||||
public void SetExecutionChanges(Guid tenantId, Guid executionId, IReadOnlyList<ClassificationChange> changes)
|
||||
=> _byExecution[(tenantId, executionId)] = changes;
|
||||
|
||||
public Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertedChanges.Add(change);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertedBatches.Add(changes.ToList());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByExecutionAsync(
|
||||
Guid tenantId,
|
||||
Guid executionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_byExecution.TryGetValue((tenantId, executionId), out var changes)
|
||||
? changes
|
||||
: Array.Empty<ClassificationChange>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(Guid tenantId, DateTimeOffset since, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(string vulnId, Guid? tenantId = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(Guid tenantId, DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
// Description: Unit tests for scan metrics repository operations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using Xunit;
|
||||
@@ -16,6 +18,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ScannerPostgresFixture _fixture;
|
||||
private IScanMetricsRepository _repository = null!;
|
||||
private NpgsqlDataSource _dataSource = null!;
|
||||
|
||||
public ScanMetricsRepositoryTests(ScannerPostgresFixture fixture)
|
||||
{
|
||||
@@ -24,11 +27,20 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.ResetAsync();
|
||||
_repository = new PostgresScanMetricsRepository(_fixture.CreateConnection);
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
// Migration 004 creates scan metrics objects under the hard-coded `scanner` schema.
|
||||
// Clear those tables explicitly for test isolation.
|
||||
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE scanner.execution_phases, scanner.scan_metrics CASCADE;");
|
||||
|
||||
_dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
_repository = new PostgresScanMetricsRepository(_dataSource, NullLogger<PostgresScanMetricsRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_InsertsNewMetrics()
|
||||
@@ -59,7 +71,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
new ExecutionPhase
|
||||
{
|
||||
MetricsId = metrics.MetricsId,
|
||||
PhaseName = "pull",
|
||||
PhaseName = ScanPhaseNames.Ingest,
|
||||
PhaseOrder = 1,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-10),
|
||||
FinishedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
|
||||
@@ -68,7 +80,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
new ExecutionPhase
|
||||
{
|
||||
MetricsId = metrics.MetricsId,
|
||||
PhaseName = "analyze",
|
||||
PhaseName = ScanPhaseNames.Analyze,
|
||||
PhaseOrder = 2,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
|
||||
FinishedAt = DateTimeOffset.UtcNow,
|
||||
@@ -80,10 +92,10 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
await _repository.SavePhasesAsync(phases, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var retrieved = await _repository.GetPhasesByMetricsIdAsync(metrics.MetricsId, CancellationToken.None);
|
||||
var retrieved = await _repository.GetPhasesAsync(metrics.MetricsId, CancellationToken.None);
|
||||
Assert.Equal(2, retrieved.Count);
|
||||
Assert.Contains(retrieved, p => p.PhaseName == "pull");
|
||||
Assert.Contains(retrieved, p => p.PhaseName == "analyze");
|
||||
Assert.Contains(retrieved, p => p.PhaseName == ScanPhaseNames.Ingest);
|
||||
Assert.Contains(retrieved, p => p.PhaseName == ScanPhaseNames.Analyze);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +109,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTteByTenantAsync_ReturnsMetricsForTenant()
|
||||
public async Task GetRecentAsync_ReturnsMetricsForTenant()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
@@ -110,7 +122,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
await _repository.SaveAsync(metricsOther, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetTteByTenantAsync(tenantId, limit: 10, CancellationToken.None);
|
||||
var result = await _repository.GetRecentAsync(tenantId, limit: 10, includeReplays: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
@@ -118,33 +130,35 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTteBySurfaceAsync_ReturnsMetricsForSurface()
|
||||
public async Task GetByArtifactAsync_ReturnsMetricsForArtifact()
|
||||
{
|
||||
// Arrange
|
||||
var surfaceId = Guid.NewGuid();
|
||||
var metrics1 = CreateTestMetrics(surfaceId: surfaceId);
|
||||
var metrics2 = CreateTestMetrics(surfaceId: surfaceId);
|
||||
var artifactDigest = $"sha256:{Guid.NewGuid():N}";
|
||||
var metrics1 = CreateTestMetrics(artifactDigest: artifactDigest);
|
||||
var metrics2 = CreateTestMetrics(artifactDigest: artifactDigest);
|
||||
var other = CreateTestMetrics();
|
||||
|
||||
await _repository.SaveAsync(metrics1, CancellationToken.None);
|
||||
await _repository.SaveAsync(metrics2, CancellationToken.None);
|
||||
await _repository.SaveAsync(other, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetTteBySurfaceAsync(surfaceId, limit: 10, CancellationToken.None);
|
||||
var result = await _repository.GetByArtifactAsync(artifactDigest, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.All(result, m => Assert.Equal(surfaceId, m.SurfaceId));
|
||||
Assert.All(result, m => Assert.Equal(artifactDigest, m.ArtifactDigest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetP50TteAsync_CalculatesMedianCorrectly()
|
||||
public async Task GetTtePercentileAsync_CalculatesMedianCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var baseTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Create metrics with different durations: 100ms, 200ms, 300ms, 400ms, 500ms
|
||||
for (int i = 1; i <= 5; i++)
|
||||
// Create metrics with different durations: 100ms, 200ms, 300ms, 400ms, 500ms.
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
var metrics = new ScanMetrics
|
||||
{
|
||||
@@ -152,22 +166,26 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
ScanId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
|
||||
ArtifactType = "oci_image",
|
||||
ArtifactType = ArtifactTypes.OciImage,
|
||||
FindingsSha256 = $"sha256:{Guid.NewGuid():N}",
|
||||
StartedAt = baseTime.AddMilliseconds(-(i * 100)),
|
||||
FinishedAt = baseTime,
|
||||
Phases = new ScanPhaseTimings
|
||||
{
|
||||
PullMs = i * 20,
|
||||
IngestMs = i * 20,
|
||||
AnalyzeMs = i * 30,
|
||||
DecideMs = i * 50
|
||||
}
|
||||
ReachabilityMs = 0,
|
||||
VexMs = 0,
|
||||
SignMs = 0,
|
||||
PublishMs = 0
|
||||
},
|
||||
ScannerVersion = "1.0.0"
|
||||
};
|
||||
await _repository.SaveAsync(metrics, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act
|
||||
var p50 = await _repository.GetP50TteAsync(tenantId, since: baseTime.AddHours(-1), CancellationToken.None);
|
||||
var p50 = await _repository.GetTtePercentileAsync(tenantId, percentile: 0.50m, since: baseTime.AddHours(-1), cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(p50);
|
||||
@@ -178,15 +196,15 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
public async Task SaveAsync_PreservesPhaseTimings()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = CreateTestMetrics();
|
||||
metrics.Phases = new ScanPhaseTimings
|
||||
var metrics = CreateTestMetrics(phases: new ScanPhaseTimings
|
||||
{
|
||||
PullMs = 100,
|
||||
IngestMs = 100,
|
||||
AnalyzeMs = 200,
|
||||
DecideMs = 150,
|
||||
AttestMs = 50,
|
||||
ReachabilityMs = 300
|
||||
};
|
||||
ReachabilityMs = 300,
|
||||
VexMs = 150,
|
||||
SignMs = 50,
|
||||
PublishMs = 25
|
||||
});
|
||||
|
||||
// Act
|
||||
await _repository.SaveAsync(metrics, CancellationToken.None);
|
||||
@@ -194,20 +212,19 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
// Assert
|
||||
var retrieved = await _repository.GetByScanIdAsync(metrics.ScanId, CancellationToken.None);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(100, retrieved.Phases.PullMs);
|
||||
Assert.Equal(100, retrieved.Phases.IngestMs);
|
||||
Assert.Equal(200, retrieved.Phases.AnalyzeMs);
|
||||
Assert.Equal(150, retrieved.Phases.DecideMs);
|
||||
Assert.Equal(50, retrieved.Phases.AttestMs);
|
||||
Assert.Equal(300, retrieved.Phases.ReachabilityMs);
|
||||
Assert.Equal(150, retrieved.Phases.VexMs);
|
||||
Assert.Equal(50, retrieved.Phases.SignMs);
|
||||
Assert.Equal(25, retrieved.Phases.PublishMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_HandlesReplayScans()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = CreateTestMetrics();
|
||||
metrics.IsReplay = true;
|
||||
metrics.ReplayManifestHash = "sha256:replay123";
|
||||
var metrics = CreateTestMetrics(isReplay: true, replayManifestHash: "sha256:replay123");
|
||||
|
||||
// Act
|
||||
await _repository.SaveAsync(metrics, CancellationToken.None);
|
||||
@@ -219,7 +236,13 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
Assert.Equal("sha256:replay123", retrieved.ReplayManifestHash);
|
||||
}
|
||||
|
||||
private static ScanMetrics CreateTestMetrics(Guid? tenantId = null, Guid? surfaceId = null)
|
||||
private static ScanMetrics CreateTestMetrics(
|
||||
Guid? tenantId = null,
|
||||
Guid? surfaceId = null,
|
||||
string? artifactDigest = null,
|
||||
ScanPhaseTimings? phases = null,
|
||||
bool isReplay = false,
|
||||
string? replayManifestHash = null)
|
||||
{
|
||||
return new ScanMetrics
|
||||
{
|
||||
@@ -227,12 +250,15 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
|
||||
ScanId = Guid.NewGuid(),
|
||||
TenantId = tenantId ?? Guid.NewGuid(),
|
||||
SurfaceId = surfaceId,
|
||||
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
|
||||
ArtifactType = "oci_image",
|
||||
ArtifactDigest = artifactDigest ?? $"sha256:{Guid.NewGuid():N}",
|
||||
ArtifactType = ArtifactTypes.OciImage,
|
||||
ReplayManifestHash = replayManifestHash,
|
||||
FindingsSha256 = $"sha256:{Guid.NewGuid():N}",
|
||||
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
FinishedAt = DateTimeOffset.UtcNow,
|
||||
Phases = new ScanPhaseTimings()
|
||||
Phases = phases ?? ScanPhaseTimings.Empty,
|
||||
ScannerVersion = "1.0.0",
|
||||
IsReplay = isReplay
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(snapshot.FindingKey.VulnId, retrieved.FindingKey.VulnId);
|
||||
Assert.Equal(snapshot.FindingKey.Purl, retrieved.FindingKey.Purl);
|
||||
Assert.Equal(snapshot.FindingKey.ComponentPurl, retrieved.FindingKey.ComponentPurl);
|
||||
Assert.Equal(snapshot.Reachable, retrieved.Reachable);
|
||||
Assert.Equal(snapshot.VexStatus, retrieved.VexStatus);
|
||||
Assert.Equal(snapshot.Kev, retrieved.Kev);
|
||||
@@ -89,11 +89,11 @@ public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
|
||||
// Arrange
|
||||
var findingKey = new FindingKey("CVE-2024-5678", "pkg:pypi/requests@2.28.0");
|
||||
|
||||
var snapshot1 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-001",
|
||||
var snapshot1 = CreateTestSnapshot(findingKey.VulnId, findingKey.ComponentPurl, "scan-001",
|
||||
capturedAt: DateTimeOffset.UtcNow.AddHours(-2));
|
||||
var snapshot2 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-002",
|
||||
var snapshot2 = CreateTestSnapshot(findingKey.VulnId, findingKey.ComponentPurl, "scan-002",
|
||||
capturedAt: DateTimeOffset.UtcNow.AddHours(-1));
|
||||
var snapshot3 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-003",
|
||||
var snapshot3 = CreateTestSnapshot(findingKey.VulnId, findingKey.ComponentPurl, "scan-003",
|
||||
capturedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
@@ -251,8 +251,8 @@ public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
// Arrange
|
||||
var findingKey = new FindingKey("CVE-2024-HIST", "pkg:npm/history@1.0.0");
|
||||
var change1 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 100);
|
||||
var change2 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 200);
|
||||
var change1 = CreateTestChange(findingKey.VulnId, findingKey.ComponentPurl, hasMaterialChange: true, priority: 100);
|
||||
var change2 = CreateTestChange(findingKey.VulnId, findingKey.ComponentPurl, hasMaterialChange: true, priority: 200);
|
||||
|
||||
await _changeRepo.StoreChangeAsync(change1, "scan-h1");
|
||||
await _changeRepo.StoreChangeAsync(change2, "scan-h2");
|
||||
|
||||
@@ -13,6 +13,8 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
@@ -30,7 +32,8 @@ public sealed class ReportEventDispatcherTests
|
||||
public async Task PublishAsync_EmitsReportReadyAndScanCompleted()
|
||||
{
|
||||
var publisher = new RecordingEventPublisher();
|
||||
var dispatcher = new ReportEventDispatcher(publisher, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var tracker = new RecordingClassificationChangeTracker();
|
||||
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var request = new ReportRequestDto
|
||||
@@ -165,6 +168,143 @@ public sealed class ReportEventDispatcherTests
|
||||
Assert.Equal("blocked", scanPayload.Report.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_RecordsFnDriftClassificationChanges()
|
||||
{
|
||||
var publisher = new RecordingEventPublisher();
|
||||
var tracker = new RecordingClassificationChangeTracker();
|
||||
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:feedface",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto
|
||||
{
|
||||
Id = "finding-1",
|
||||
Severity = "Critical",
|
||||
Repository = "acme/edge/api",
|
||||
Cve = "CVE-2024-9999",
|
||||
Purl = "pkg:nuget/Acme.Edge.Api@1.2.3",
|
||||
Tags = new[] { "reachability:runtime" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
||||
var projected = new PolicyVerdict(
|
||||
"finding-1",
|
||||
PolicyVerdictStatus.Blocked,
|
||||
Score: 47.5,
|
||||
ConfigVersion: "1.0",
|
||||
SourceTrust: "NVD",
|
||||
Reachability: "runtime");
|
||||
|
||||
var preview = new PolicyPreviewResponse(
|
||||
Success: true,
|
||||
PolicyDigest: "digest-123",
|
||||
RevisionId: "rev-42",
|
||||
Issues: ImmutableArray<PolicyIssue>.Empty,
|
||||
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
||||
ChangedCount: 1);
|
||||
|
||||
var document = new ReportDocumentDto
|
||||
{
|
||||
ReportId = "report-abc",
|
||||
ImageDigest = "sha256:feedface",
|
||||
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
||||
Verdict = "blocked",
|
||||
Policy = new ReportPolicyDto
|
||||
{
|
||||
RevisionId = "rev-42",
|
||||
Digest = "digest-123"
|
||||
},
|
||||
Summary = new ReportSummaryDto
|
||||
{
|
||||
Total = 1,
|
||||
Blocked = 1,
|
||||
Warned = 0,
|
||||
Ignored = 0,
|
||||
Quieted = 0
|
||||
}
|
||||
};
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") }));
|
||||
|
||||
await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken);
|
||||
|
||||
var change = Assert.Single(tracker.Changes);
|
||||
Assert.Equal("sha256:feedface", change.ArtifactDigest);
|
||||
Assert.Equal("CVE-2024-9999", change.VulnId);
|
||||
Assert.Equal("pkg:nuget/Acme.Edge.Api@1.2.3", change.PackagePurl);
|
||||
Assert.Equal(ClassificationStatus.Unaffected, change.PreviousStatus);
|
||||
Assert.Equal(ClassificationStatus.Affected, change.NewStatus);
|
||||
Assert.Equal(DriftCause.ReachabilityDelta, change.Cause);
|
||||
Assert.Equal(document.GeneratedAt, change.ChangedAt);
|
||||
Assert.NotEqual(Guid.Empty, change.TenantId);
|
||||
Assert.NotEqual(Guid.Empty, change.ExecutionId);
|
||||
Assert.NotEqual(Guid.Empty, change.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_DoesNotFailWhenFnDriftTrackingThrows()
|
||||
{
|
||||
var publisher = new RecordingEventPublisher();
|
||||
var tracker = new RecordingClassificationChangeTracker
|
||||
{
|
||||
ThrowOnTrack = true
|
||||
};
|
||||
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:feedface",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto
|
||||
{
|
||||
Id = "finding-1",
|
||||
Severity = "Critical",
|
||||
Repository = "acme/edge/api",
|
||||
Cve = "CVE-2024-9999",
|
||||
Purl = "pkg:nuget/Acme.Edge.Api@1.2.3"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
||||
var projected = new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked, ConfigVersion: "1.0");
|
||||
|
||||
var preview = new PolicyPreviewResponse(
|
||||
Success: true,
|
||||
PolicyDigest: "digest-123",
|
||||
RevisionId: "rev-42",
|
||||
Issues: ImmutableArray<PolicyIssue>.Empty,
|
||||
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
||||
ChangedCount: 1);
|
||||
|
||||
var document = new ReportDocumentDto
|
||||
{
|
||||
ReportId = "report-abc",
|
||||
ImageDigest = "sha256:feedface",
|
||||
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
||||
Verdict = "blocked",
|
||||
Policy = new ReportPolicyDto(),
|
||||
Summary = new ReportSummaryDto()
|
||||
};
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") }));
|
||||
|
||||
await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken);
|
||||
|
||||
Assert.Equal(2, publisher.Events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_HonoursConfiguredConsoleAndApiSegments()
|
||||
{
|
||||
@@ -186,7 +326,8 @@ public sealed class ReportEventDispatcherTests
|
||||
});
|
||||
|
||||
var publisher = new RecordingEventPublisher();
|
||||
var dispatcher = new ReportEventDispatcher(publisher, options, TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var tracker = new RecordingClassificationChangeTracker();
|
||||
var dispatcher = new ReportEventDispatcher(publisher, tracker, options, TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var request = new ReportRequestDto
|
||||
@@ -295,4 +436,40 @@ public sealed class ReportEventDispatcherTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingClassificationChangeTracker : IClassificationChangeTracker
|
||||
{
|
||||
public List<ClassificationChange> Changes { get; } = new();
|
||||
public bool ThrowOnTrack { get; init; }
|
||||
|
||||
public Task TrackChangeAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnTrack)
|
||||
{
|
||||
throw new InvalidOperationException("Tracking failure");
|
||||
}
|
||||
|
||||
Changes.Add(change);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task TrackChangesAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ThrowOnTrack)
|
||||
{
|
||||
throw new InvalidOperationException("Tracking failure");
|
||||
}
|
||||
|
||||
Changes.AddRange(changes);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ClassificationChange>> ComputeDeltaAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
Guid previousExecutionId,
|
||||
Guid currentExecutionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user