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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user