work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -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.");
}
}

View File

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

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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>

View File

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

View File

@@ -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();
}
}

View File

@@ -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
};
}
}

View File

@@ -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");

View File

@@ -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>());
}
}