feat(telemetry): add telemetry client and services for tracking events

- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint.
- Created TtfsTelemetryService for emitting specific telemetry events related to TTFS.
- Added tests for TelemetryClient to ensure event queuing and flushing functionality.
- Introduced models for reachability drift detection, including DriftResult and DriftedSink.
- Developed DriftApiService for interacting with the drift detection API.
- Updated FirstSignalCardComponent to emit telemetry events on signal appearance.
- Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -0,0 +1,341 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.Emit.Native;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Native;
/// <summary>
/// Unit tests for <see cref="NativePurlBuilder"/>.
/// Sprint: SPRINT_3500_0012_0001
/// Task: BSE-008
/// </summary>
public sealed class NativePurlBuilderTests
{
private readonly NativePurlBuilder _builder = new();
#region FromIndexResult Tests
[Fact]
public void FromIndexResult_ReturnsPurlFromResult()
{
var result = new BuildIdLookupResult(
BuildId: "gnu-build-id:abc123",
Purl: "pkg:deb/debian/libc6@2.31",
Version: "2.31",
SourceDistro: "debian",
Confidence: BuildIdConfidence.Exact,
IndexedAt: DateTimeOffset.UtcNow);
var purl = _builder.FromIndexResult(result);
Assert.Equal("pkg:deb/debian/libc6@2.31", purl);
}
[Fact]
public void FromIndexResult_ThrowsForNull()
{
Assert.Throws<ArgumentNullException>(() => _builder.FromIndexResult(null!));
}
#endregion
#region FromUnresolvedBinary Tests
[Fact]
public void FromUnresolvedBinary_GeneratesGenericPurl()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.StartsWith("pkg:generic/libssl.so.3@unknown", purl);
}
[Fact]
public void FromUnresolvedBinary_IncludesBuildId()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
BuildId = "gnu-build-id:abc123def456"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.Contains("build-id=gnu-build-id%3Aabc123def456", purl);
}
[Fact]
public void FromUnresolvedBinary_IncludesArchitecture()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
Architecture = "x86_64"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.Contains("arch=x86_64", purl);
}
[Fact]
public void FromUnresolvedBinary_IncludesPlatform()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
Platform = "linux"
};
var purl = _builder.FromUnresolvedBinary(metadata);
Assert.Contains("os=linux", purl);
}
[Fact]
public void FromUnresolvedBinary_SortsQualifiersAlphabetically()
{
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libssl.so.3",
BuildId = "gnu-build-id:abc",
Architecture = "x86_64",
Platform = "linux"
};
var purl = _builder.FromUnresolvedBinary(metadata);
// arch < build-id < os (alphabetical)
var archIndex = purl.IndexOf("arch=", StringComparison.Ordinal);
var buildIdIndex = purl.IndexOf("build-id=", StringComparison.Ordinal);
var osIndex = purl.IndexOf("os=", StringComparison.Ordinal);
Assert.True(archIndex < buildIdIndex);
Assert.True(buildIdIndex < osIndex);
}
#endregion
#region FromDistroPackage Tests
[Theory]
[InlineData("deb", "debian", "pkg:deb/debian/libc6@2.31")]
[InlineData("debian", "debian", "pkg:deb/debian/libc6@2.31")]
[InlineData("ubuntu", "ubuntu", "pkg:deb/ubuntu/libc6@2.31")]
[InlineData("rpm", "fedora", "pkg:rpm/fedora/libc6@2.31")]
[InlineData("apk", "alpine", "pkg:apk/alpine/libc6@2.31")]
[InlineData("pacman", "arch", "pkg:pacman/arch/libc6@2.31")]
public void FromDistroPackage_MapsDistroToPurlType(string distro, string distroName, string expectedPrefix)
{
var purl = _builder.FromDistroPackage(distro, distroName, "libc6", "2.31");
Assert.StartsWith(expectedPrefix, purl);
}
[Fact]
public void FromDistroPackage_IncludesArchitecture()
{
var purl = _builder.FromDistroPackage("deb", "debian", "libc6", "2.31", "amd64");
Assert.Equal("pkg:deb/debian/libc6@2.31?arch=amd64", purl);
}
[Fact]
public void FromDistroPackage_ThrowsForNullDistro()
{
Assert.ThrowsAny<ArgumentException>(() =>
_builder.FromDistroPackage(null!, "debian", "libc6", "2.31"));
}
[Fact]
public void FromDistroPackage_ThrowsForNullPackageName()
{
Assert.ThrowsAny<ArgumentException>(() =>
_builder.FromDistroPackage("deb", "debian", null!, "2.31"));
}
#endregion
}
/// <summary>
/// Unit tests for <see cref="NativeComponentEmitter"/>.
/// Sprint: SPRINT_3500_0012_0001
/// Task: BSE-008
/// </summary>
public sealed class NativeComponentEmitterTests
{
#region EmitAsync Tests
[Fact]
public async Task EmitAsync_UsesIndexMatch_WhenFound()
{
var index = new FakeBuildIdIndex();
index.AddEntry("gnu-build-id:abc123", new BuildIdLookupResult(
BuildId: "gnu-build-id:abc123",
Purl: "pkg:deb/debian/libc6@2.31",
Version: "2.31",
SourceDistro: "debian",
Confidence: BuildIdConfidence.Exact,
IndexedAt: DateTimeOffset.UtcNow));
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libc.so.6",
BuildId = "gnu-build-id:abc123"
};
var result = await emitter.EmitAsync(metadata);
Assert.True(result.IndexMatch);
Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl);
Assert.Equal("2.31", result.Version);
Assert.NotNull(result.LookupResult);
}
[Fact]
public async Task EmitAsync_FallsBackToGenericPurl_WhenNotFound()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/usr/lib/libcustom.so",
BuildId = "gnu-build-id:notfound"
};
var result = await emitter.EmitAsync(metadata);
Assert.False(result.IndexMatch);
Assert.StartsWith("pkg:generic/libcustom.so@unknown", result.Purl);
Assert.Null(result.LookupResult);
}
[Fact]
public async Task EmitAsync_ExtractsFilename()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "elf",
FilePath = "/very/deep/path/to/libfoo.so.1.2.3"
};
var result = await emitter.EmitAsync(metadata);
Assert.Equal("libfoo.so.1.2.3", result.Name);
}
[Fact]
public async Task EmitAsync_UsesProductVersion_WhenNotInIndex()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadata = new NativeBinaryMetadata
{
Format = "pe",
FilePath = "C:\\Windows\\System32\\kernel32.dll",
ProductVersion = "10.0.19041.1"
};
var result = await emitter.EmitAsync(metadata);
Assert.Equal("10.0.19041.1", result.Version);
}
#endregion
#region EmitBatchAsync Tests
[Fact]
public async Task EmitBatchAsync_ProcessesMultipleBinaries()
{
var index = new FakeBuildIdIndex();
index.AddEntry("gnu-build-id:aaa", new BuildIdLookupResult(
"gnu-build-id:aaa", "pkg:deb/debian/liba@1.0", "1.0", "debian", BuildIdConfidence.Exact, DateTimeOffset.UtcNow));
index.AddEntry("gnu-build-id:bbb", new BuildIdLookupResult(
"gnu-build-id:bbb", "pkg:deb/debian/libb@2.0", "2.0", "debian", BuildIdConfidence.Exact, DateTimeOffset.UtcNow));
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var metadataList = new[]
{
new NativeBinaryMetadata { Format = "elf", FilePath = "/lib/liba.so", BuildId = "gnu-build-id:aaa" },
new NativeBinaryMetadata { Format = "elf", FilePath = "/lib/libb.so", BuildId = "gnu-build-id:bbb" },
new NativeBinaryMetadata { Format = "elf", FilePath = "/lib/libc.so", BuildId = "gnu-build-id:ccc" }
};
var results = await emitter.EmitBatchAsync(metadataList);
Assert.Equal(3, results.Count);
Assert.Equal(2, results.Count(r => r.IndexMatch));
Assert.Equal(1, results.Count(r => !r.IndexMatch));
}
[Fact]
public async Task EmitBatchAsync_ReturnsEmptyForEmptyInput()
{
var index = new FakeBuildIdIndex();
var emitter = new NativeComponentEmitter(index, NullLogger<NativeComponentEmitter>.Instance);
var results = await emitter.EmitBatchAsync(Array.Empty<NativeBinaryMetadata>());
Assert.Empty(results);
}
#endregion
#region Test Helpers
private sealed class FakeBuildIdIndex : IBuildIdIndex
{
private readonly Dictionary<string, BuildIdLookupResult> _entries = new(StringComparer.OrdinalIgnoreCase);
public int Count => _entries.Count;
public bool IsLoaded => true;
public void AddEntry(string buildId, BuildIdLookupResult result)
{
_entries[buildId] = result;
}
public Task<BuildIdLookupResult?> LookupAsync(string buildId, CancellationToken cancellationToken = default)
{
_entries.TryGetValue(buildId, out var result);
return Task.FromResult(result);
}
public Task<IReadOnlyList<BuildIdLookupResult>> BatchLookupAsync(
IEnumerable<string> buildIds,
CancellationToken cancellationToken = default)
{
var results = buildIds
.Where(id => _entries.ContainsKey(id))
.Select(id => _entries[id])
.ToList();
return Task.FromResult<IReadOnlyList<BuildIdLookupResult>>(results);
}
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
#endregion
}

View File

@@ -0,0 +1,445 @@
// -----------------------------------------------------------------------------
// PathExplanationServiceTests.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Unit tests for PathExplanationService.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Explanation;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class PathExplanationServiceTests
{
private readonly PathExplanationService _service;
private readonly PathRenderer _renderer;
public PathExplanationServiceTests()
{
_service = new PathExplanationService(
NullLogger<PathExplanationService>.Instance);
_renderer = new PathRenderer();
}
[Fact]
public async Task ExplainAsync_WithSimplePath_ReturnsExplainedPath()
{
// Arrange
var graph = CreateSimpleGraph();
var query = new PathExplanationQuery();
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
Assert.True(result.TotalCount >= 0);
}
[Fact]
public async Task ExplainAsync_WithSinkFilter_FiltersResults()
{
// Arrange
var graph = CreateGraphWithMultipleSinks();
var query = new PathExplanationQuery { SinkId = "sink-1" };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.Equal("sink-1", path.SinkId);
}
}
[Fact]
public async Task ExplainAsync_WithGatesFilter_FiltersPathsWithGates()
{
// Arrange
var graph = CreateGraphWithGates();
var query = new PathExplanationQuery { HasGates = true };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.True(path.Gates.Count > 0);
}
}
[Fact]
public async Task ExplainAsync_WithMaxPathLength_LimitsPathLength()
{
// Arrange
var graph = CreateDeepGraph(10);
var query = new PathExplanationQuery { MaxPathLength = 5 };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.True(path.PathLength <= 5);
}
}
[Fact]
public async Task ExplainAsync_WithMaxPaths_LimitsResults()
{
// Arrange
var graph = CreateGraphWithMultiplePaths(20);
var query = new PathExplanationQuery { MaxPaths = 5 };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
Assert.True(result.Paths.Count <= 5);
if (result.TotalCount > 5)
{
Assert.True(result.HasMore);
}
}
[Fact]
public void Renderer_Text_ProducesExpectedFormat()
{
// Arrange
var path = CreateTestPath();
// Act
var text = _renderer.Render(path, PathOutputFormat.Text);
// Assert
Assert.Contains(path.EntrypointSymbol, text);
Assert.Contains("SINK:", text);
}
[Fact]
public void Renderer_Markdown_ProducesExpectedFormat()
{
// Arrange
var path = CreateTestPath();
// Act
var markdown = _renderer.Render(path, PathOutputFormat.Markdown);
// Assert
Assert.Contains("###", markdown);
Assert.Contains("```", markdown);
Assert.Contains(path.EntrypointSymbol, markdown);
}
[Fact]
public void Renderer_Json_ProducesValidJson()
{
// Arrange
var path = CreateTestPath();
// Act
var json = _renderer.Render(path, PathOutputFormat.Json);
// Assert
Assert.StartsWith("{", json.Trim());
Assert.EndsWith("}", json.Trim());
Assert.Contains("sink_id", json);
Assert.Contains("entrypoint_id", json);
}
[Fact]
public void Renderer_WithGates_IncludesGateInfo()
{
// Arrange
var path = CreateTestPathWithGates();
// Act
var text = _renderer.Render(path, PathOutputFormat.Text);
// Assert
Assert.Contains("Gates:", text);
Assert.Contains("multiplier", text.ToLowerInvariant());
}
[Fact]
public async Task ExplainPathAsync_WithValidId_ReturnsPath()
{
// Arrange
var graph = CreateSimpleGraph();
// This test verifies the API works, actual path lookup depends on graph structure
// Act
var result = await _service.ExplainPathAsync(graph, "entry-1:sink-1:0");
// The result may be null if path doesn't exist, that's OK
Assert.True(result is null || result.PathId is not null);
}
[Fact]
public void GateMultiplier_Calculation_IsCorrect()
{
// Arrange - path with auth gate
var pathWithAuth = CreateTestPathWithGates();
// Assert - auth gate should reduce multiplier
Assert.True(pathWithAuth.GateMultiplierBps < 10000);
}
[Fact]
public void PathWithoutGates_HasFullMultiplier()
{
// Arrange
var path = CreateTestPath();
// Assert - no gates = 100% multiplier
Assert.Equal(10000, path.GateMultiplierBps);
}
private static RichGraph CreateSimpleGraph()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Nodes = new[]
{
new RichGraphNode(
Id: "entry-1",
SymbolId: "Handler.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "GET /users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new RichGraphNode(
Id: "sink-1",
SymbolId: "DB.query",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "sql_sink",
Display: "executeQuery",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null)
}
};
}
private static RichGraph CreateGraphWithMultipleSinks()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink1", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null),
new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null),
new RichGraphEdge("entry-1", "sink-2", "call", null)
}
};
}
private static RichGraph CreateGraphWithGates()
{
var gates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "@Authenticated",
GuardSymbol = "AuthFilter",
Confidence = 0.9,
DetectionMethod = "annotation"
}
};
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", gates)
}
};
}
private static RichGraph CreateDeepGraph(int depth)
{
var nodes = new List<RichGraphNode>();
var edges = new List<RichGraphEdge>();
for (var i = 0; i < depth; i++)
{
var attrs = i == depth - 1
? new Dictionary<string, string> { ["is_sink"] = "true" }
: null;
nodes.Add(new RichGraphNode($"node-{i}", $"Method{i}", null, null, "java", i == depth - 1 ? "sink" : "method", null, null, null, attrs, null));
if (i > 0)
{
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null));
}
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("node-0", "runtime", null) },
Nodes = nodes,
Edges = edges
};
}
private static RichGraph CreateGraphWithMultiplePaths(int pathCount)
{
var nodes = new List<RichGraphNode>
{
new("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null)
};
var edges = new List<RichGraphEdge>();
for (var i = 0; i < pathCount; i++)
{
nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null));
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = nodes,
Edges = edges
};
}
private static ExplainedPath CreateTestPath()
{
return new ExplainedPath
{
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
PathLength = 2,
Hops = new[]
{
new ExplainedPathHop
{
NodeId = "entry-1",
Symbol = "Handler.handle",
Package = "app",
Depth = 0,
IsEntrypoint = true,
IsSink = false
},
new ExplainedPathHop
{
NodeId = "sink-1",
Symbol = "DB.query",
Package = "database",
Depth = 1,
IsEntrypoint = false,
IsSink = true
}
},
Gates = Array.Empty<DetectedGate>(),
GateMultiplierBps = 10000
};
}
private static ExplainedPath CreateTestPathWithGates()
{
return new ExplainedPath
{
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
PathLength = 2,
Hops = new[]
{
new ExplainedPathHop
{
NodeId = "entry-1",
Symbol = "Handler.handle",
Package = "app",
Depth = 0,
IsEntrypoint = true,
IsSink = false
},
new ExplainedPathHop
{
NodeId = "sink-1",
Symbol = "DB.query",
Package = "database",
Depth = 1,
IsEntrypoint = false,
IsSink = true
}
},
Gates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "@Authenticated",
GuardSymbol = "AuthFilter",
Confidence = 0.9,
DetectionMethod = "annotation"
}
},
GateMultiplierBps = 3000
};
}
}

View File

@@ -0,0 +1,412 @@
// -----------------------------------------------------------------------------
// RichGraphBoundaryExtractorTests.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Unit tests for RichGraphBoundaryExtractor.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Boundary;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class RichGraphBoundaryExtractorTests
{
private readonly RichGraphBoundaryExtractor _extractor;
public RichGraphBoundaryExtractorTests()
{
_extractor = new RichGraphBoundaryExtractor(
NullLogger<RichGraphBoundaryExtractor>.Instance);
}
[Fact]
public void Extract_HttpRoot_ReturnsBoundaryWithApiSurface()
{
var root = new RichGraphRoot("root-http", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "com.example.Controller.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "POST /api/users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("network", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("api", result.Surface.Type);
Assert.Equal("https", result.Surface.Protocol);
}
[Fact]
public void Extract_GrpcRoot_ReturnsBoundaryWithGrpcProtocol()
{
var root = new RichGraphRoot("root-grpc", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "com.example.UserService.getUser",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "grpc_method",
Display: "UserService.GetUser",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.NotNull(result.Surface);
Assert.Equal("grpc", result.Surface.Protocol);
}
[Fact]
public void Extract_CliRoot_ReturnsProcessBoundary()
{
var root = new RichGraphRoot("root-cli", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Main",
CodeId: null,
Purl: null,
Lang: "csharp",
Kind: "cli_command",
Display: "stella scan",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("process", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("cli", result.Surface.Type);
}
[Fact]
public void Extract_LibraryPhase_ReturnsLibraryBoundary()
{
var root = new RichGraphRoot("root-lib", "library", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Utils.parseJson",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "function",
Display: "parseJson",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("library", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("library", result.Surface.Type);
}
[Fact]
public void Extract_WithAuthGate_SetsAuthRequired()
{
var root = new RichGraphRoot("root-auth", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Controller.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "JWT token required",
GuardSymbol = "AuthFilter.doFilter",
Confidence = 0.9,
DetectionMethod = "pattern_match"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Auth);
Assert.True(result.Auth.Required);
Assert.Equal("jwt", result.Auth.Type);
}
[Fact]
public void Extract_WithAdminGate_SetsAdminRole()
{
var root = new RichGraphRoot("root-admin", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "AdminController.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AdminOnly,
Detail = "Requires admin role",
GuardSymbol = "RoleFilter.check",
Confidence = 0.85,
DetectionMethod = "annotation"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Auth);
Assert.True(result.Auth.Required);
Assert.NotNull(result.Auth.Roles);
Assert.Contains("admin", result.Auth.Roles);
}
[Fact]
public void Extract_WithFeatureFlagGate_AddsControl()
{
var root = new RichGraphRoot("root-ff", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "BetaFeature.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.FeatureFlag,
Detail = "beta_users_only",
GuardSymbol = "FeatureFlags.isEnabled",
Confidence = 0.95,
DetectionMethod = "call_analysis"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Controls);
Assert.Single(result.Controls);
Assert.Equal("feature_flag", result.Controls[0].Type);
Assert.True(result.Controls[0].Active);
}
[Fact]
public void Extract_WithInternetFacingContext_SetsExposure()
{
var root = new RichGraphRoot("root-public", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "PublicApi.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.ForEnvironment(
"production",
isInternetFacing: true,
networkZone: "dmz");
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Exposure);
Assert.True(result.Exposure.InternetFacing);
Assert.Equal("dmz", result.Exposure.Zone);
Assert.Equal("public", result.Exposure.Level);
}
[Fact]
public void Extract_InternalService_SetsInternalExposure()
{
var root = new RichGraphRoot("root-internal", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "InternalService.process",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "internal_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.NotNull(result.Exposure);
Assert.False(result.Exposure.InternetFacing);
Assert.Equal("internal", result.Exposure.Level);
}
[Fact]
public void Extract_SetsConfidenceBasedOnContext()
{
var root = new RichGraphRoot("root-1", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
// Empty context should have lower confidence
var emptyResult = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
// Rich context should have higher confidence
var richContext = new BoundaryExtractionContext
{
IsInternetFacing = true,
NetworkZone = "dmz",
DetectedGates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "auth",
GuardSymbol = "auth",
Confidence = 0.9,
DetectionMethod = "test"
}
}
};
var richResult = _extractor.Extract(root, rootNode, richContext);
Assert.NotNull(emptyResult);
Assert.NotNull(richResult);
Assert.True(richResult.Confidence > emptyResult.Confidence);
}
[Fact]
public void Extract_IsDeterministic()
{
var root = new RichGraphRoot("root-det", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "GET /api/test",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "JWT",
GuardSymbol = "Auth",
Confidence = 0.9,
DetectionMethod = "test"
}
});
var result1 = _extractor.Extract(root, rootNode, context);
var result2 = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1.Kind, result2.Kind);
Assert.Equal(result1.Surface?.Type, result2.Surface?.Type);
Assert.Equal(result1.Auth?.Required, result2.Auth?.Required);
Assert.Equal(result1.Confidence, result2.Confidence);
}
[Fact]
public void CanHandle_AlwaysReturnsTrue()
{
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.Empty));
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.ForEnvironment("test")));
}
[Fact]
public void Priority_ReturnsBaseValue()
{
Assert.Equal(100, _extractor.Priority);
}
[Fact]
public async Task ExtractAsync_ReturnsResult()
{
var root = new RichGraphRoot("root-async", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = await _extractor.ExtractAsync(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("network", result.Kind);
}
}

View File

@@ -0,0 +1,289 @@
// -----------------------------------------------------------------------------
// EpssProviderTests.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-010
// Description: Unit tests for EpssProvider.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
/// <summary>
/// Unit tests for <see cref="EpssProvider"/>.
/// </summary>
public sealed class EpssProviderTests
{
private readonly Mock<IEpssRepository> _mockRepository;
private readonly EpssProviderOptions _options;
private readonly FakeTimeProvider _timeProvider;
private readonly EpssProvider _provider;
public EpssProviderTests()
{
_mockRepository = new Mock<IEpssRepository>();
_options = new EpssProviderOptions
{
EnableCache = false,
MaxBatchSize = 100,
SourceIdentifier = "test"
};
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero));
_provider = new EpssProvider(
_mockRepository.Object,
Options.Create(_options),
NullLogger<EpssProvider>.Instance,
_timeProvider);
}
#region GetCurrentAsync Tests
[Fact]
public async Task GetCurrentAsync_ReturnsEvidence_WhenFound()
{
var cveId = "CVE-2021-44228";
var modelDate = new DateOnly(2025, 12, 17);
var entry = new EpssCurrentEntry(cveId, 0.97, 0.99, modelDate, Guid.NewGuid());
_mockRepository
.Setup(r => r.GetCurrentAsync(It.Is<IEnumerable<string>>(ids => ids.Contains(cveId)), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry> { [cveId] = entry });
var result = await _provider.GetCurrentAsync(cveId);
Assert.NotNull(result);
Assert.Equal(cveId, result.CveId);
Assert.Equal(0.97, result.Score);
Assert.Equal(0.99, result.Percentile);
Assert.Equal(modelDate, result.ModelDate);
Assert.Equal("test", result.Source);
}
[Fact]
public async Task GetCurrentAsync_ReturnsNull_WhenNotFound()
{
var cveId = "CVE-9999-99999";
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>());
var result = await _provider.GetCurrentAsync(cveId);
Assert.Null(result);
}
[Fact]
public async Task GetCurrentAsync_ThrowsForNullCveId()
{
await Assert.ThrowsAnyAsync<ArgumentException>(() => _provider.GetCurrentAsync(null!));
}
[Fact]
public async Task GetCurrentAsync_ThrowsForEmptyCveId()
{
await Assert.ThrowsAnyAsync<ArgumentException>(() => _provider.GetCurrentAsync(""));
}
#endregion
#region GetCurrentBatchAsync Tests
[Fact]
public async Task GetCurrentBatchAsync_ReturnsBatchResult()
{
var cveIds = new[] { "CVE-2021-44228", "CVE-2022-22965", "CVE-9999-99999" };
var modelDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
var results = new Dictionary<string, EpssCurrentEntry>
{
["CVE-2021-44228"] = new("CVE-2021-44228", 0.97, 0.99, modelDate, runId),
["CVE-2022-22965"] = new("CVE-2022-22965", 0.95, 0.98, modelDate, runId)
};
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(results);
var batch = await _provider.GetCurrentBatchAsync(cveIds);
Assert.Equal(2, batch.Found.Count);
Assert.Single(batch.NotFound);
Assert.Contains("CVE-9999-99999", batch.NotFound);
Assert.Equal(modelDate, batch.ModelDate);
}
[Fact]
public async Task GetCurrentBatchAsync_ReturnsEmptyForEmptyInput()
{
var batch = await _provider.GetCurrentBatchAsync(Array.Empty<string>());
Assert.Empty(batch.Found);
Assert.Empty(batch.NotFound);
Assert.Equal(0, batch.LookupTimeMs);
}
[Fact]
public async Task GetCurrentBatchAsync_DeduplicatesCveIds()
{
var cveIds = new[] { "CVE-2021-44228", "cve-2021-44228", "CVE-2021-44228" };
var modelDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
_mockRepository
.Setup(r => r.GetCurrentAsync(
It.Is<IEnumerable<string>>(ids => ids.Count() == 1),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>
{
["CVE-2021-44228"] = new("CVE-2021-44228", 0.97, 0.99, modelDate, runId)
});
var batch = await _provider.GetCurrentBatchAsync(cveIds);
Assert.Single(batch.Found);
_mockRepository.Verify(
r => r.GetCurrentAsync(It.Is<IEnumerable<string>>(ids => ids.Count() == 1), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task GetCurrentBatchAsync_TruncatesOverMaxBatchSize()
{
// Create more CVEs than max batch size
var cveIds = Enumerable.Range(1, 150).Select(i => $"CVE-2021-{i:D5}").ToArray();
_mockRepository
.Setup(r => r.GetCurrentAsync(
It.Is<IEnumerable<string>>(ids => ids.Count() <= _options.MaxBatchSize),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>());
var batch = await _provider.GetCurrentBatchAsync(cveIds);
_mockRepository.Verify(
r => r.GetCurrentAsync(
It.Is<IEnumerable<string>>(ids => ids.Count() == _options.MaxBatchSize),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region GetHistoryAsync Tests
[Fact]
public async Task GetHistoryAsync_ReturnsFilteredResults()
{
var cveId = "CVE-2021-44228";
var startDate = new DateOnly(2025, 12, 15);
var endDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
var history = new List<EpssHistoryEntry>
{
new(new DateOnly(2025, 12, 14), 0.95, 0.97, runId), // Before range
new(new DateOnly(2025, 12, 15), 0.96, 0.98, runId), // In range
new(new DateOnly(2025, 12, 16), 0.96, 0.98, runId), // In range
new(new DateOnly(2025, 12, 17), 0.97, 0.99, runId), // In range
new(new DateOnly(2025, 12, 18), 0.97, 0.99, runId), // After range
};
_mockRepository
.Setup(r => r.GetHistoryAsync(cveId, It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(history);
var result = await _provider.GetHistoryAsync(cveId, startDate, endDate);
Assert.Equal(3, result.Count);
Assert.All(result, e => Assert.True(e.ModelDate >= startDate && e.ModelDate <= endDate));
Assert.Equal(startDate, result.First().ModelDate);
Assert.Equal(endDate, result.Last().ModelDate);
}
[Fact]
public async Task GetHistoryAsync_ReturnsEmpty_WhenStartAfterEnd()
{
var cveId = "CVE-2021-44228";
var startDate = new DateOnly(2025, 12, 17);
var endDate = new DateOnly(2025, 12, 15);
var result = await _provider.GetHistoryAsync(cveId, startDate, endDate);
Assert.Empty(result);
_mockRepository.Verify(r => r.GetHistoryAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
#endregion
#region IsAvailableAsync Tests
[Fact]
public async Task IsAvailableAsync_ReturnsTrue_WhenDataExists()
{
var modelDate = new DateOnly(2025, 12, 17);
var runId = Guid.NewGuid();
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>
{
["CVE-2021-44228"] = new("CVE-2021-44228", 0.97, 0.99, modelDate, runId)
});
var result = await _provider.IsAvailableAsync();
Assert.True(result);
}
[Fact]
public async Task IsAvailableAsync_ReturnsFalse_WhenNoData()
{
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, EpssCurrentEntry>());
var result = await _provider.IsAvailableAsync();
Assert.False(result);
}
[Fact]
public async Task IsAvailableAsync_ReturnsFalse_WhenExceptionThrown()
{
_mockRepository
.Setup(r => r.GetCurrentAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Database unavailable"));
var result = await _provider.IsAvailableAsync();
Assert.False(result);
}
#endregion
#region Test Helpers
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);
}
#endregion
}

View File

@@ -5,6 +5,12 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres.Testing\\StellaOps.Infrastructure.Postgres.Testing.csproj" />