Add Canonical JSON serialization library with tests and documentation
- Implemented CanonJson class for deterministic JSON serialization and hashing. - Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters. - Created project files for the Canonical JSON library and its tests, including necessary package references. - Added README.md for library usage and API reference. - Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
@@ -4,6 +4,10 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ReachabilityAnalyzer"/>.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A) - determinism contract tests.
|
||||
/// </summary>
|
||||
public class ReachabilityAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -63,4 +67,321 @@ public class ReachabilityAnalyzerTests
|
||||
Assert.Empty(result.Paths);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.ResultDigest));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify deterministic path ordering (SinkId ASC, EntrypointId ASC, PathLength ASC).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_PathsAreDeterministicallyOrdered_BySinkIdThenEntrypointIdThenLength()
|
||||
{
|
||||
// Arrange: create graph with multiple entrypoints and sinks
|
||||
var entry1 = "entry:aaa";
|
||||
var entry2 = "entry:bbb";
|
||||
var mid1 = "mid:001";
|
||||
var mid2 = "mid:002";
|
||||
var sink1 = "sink:zzz"; // lexicographically last
|
||||
var sink2 = "sink:aaa"; // lexicographically first
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry1, "Entry1", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(entry2, "Entry2", "f.cs", 2, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid1, "Mid1", "f.cs", 3, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(mid2, "Mid2", "f.cs", 4, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink1, "Sink1", "f.cs", 5, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
new CallGraphNode(sink2, "Sink2", "f.cs", 6, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.SqlRaw),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
// entry1 -> mid1 -> sink2 (path length 3)
|
||||
new CallGraphEdge(entry1, mid1, CallKind.Direct),
|
||||
new CallGraphEdge(mid1, sink2, CallKind.Direct),
|
||||
// entry2 -> sink1 (path length 2, shorter)
|
||||
new CallGraphEdge(entry2, sink1, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry2, entry1], // deliberately out of order
|
||||
SinkIds: [sink1, sink2]); // deliberately out of order
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: paths should be ordered by SinkId ASC
|
||||
Assert.Equal(2, result.Paths.Length);
|
||||
Assert.Equal(sink2, result.Paths[0].SinkId); // "sink:aaa" comes before "sink:zzz"
|
||||
Assert.Equal(sink1, result.Paths[1].SinkId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify that multiple runs produce identical results (determinism).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_ProducesIdenticalResults_OnMultipleRuns()
|
||||
{
|
||||
var entry = "entry:test";
|
||||
var mid = "mid:test";
|
||||
var sink = "sink:test";
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid, "Mid", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink, "Sink", "f.cs", 3, "lib", 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();
|
||||
|
||||
// Act: run analysis multiple times
|
||||
var result1 = analyzer.Analyze(snapshot);
|
||||
var result2 = analyzer.Analyze(snapshot);
|
||||
var result3 = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: all results should have identical digests (determinism proof)
|
||||
Assert.Equal(result1.ResultDigest, result2.ResultDigest);
|
||||
Assert.Equal(result2.ResultDigest, result3.ResultDigest);
|
||||
Assert.Equal(result1.Paths.Length, result2.Paths.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify MaxTotalPaths limit is enforced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithOptions_RespectsMaxTotalPathsLimit()
|
||||
{
|
||||
// Arrange: create graph with 5 sinks reachable from 1 entrypoint
|
||||
var entry = "entry:test";
|
||||
var nodes = new List<CallGraphNode>
|
||||
{
|
||||
new(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
};
|
||||
var edges = new List<CallGraphEdge>();
|
||||
var sinks = new List<string>();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var sink = $"sink:{i:D3}";
|
||||
sinks.Add(sink);
|
||||
nodes.Add(new CallGraphNode(sink, $"Sink{i}", "f.cs", i + 10, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec));
|
||||
edges.Add(new CallGraphEdge(entry, sink, CallKind.Direct));
|
||||
}
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray(),
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: sinks.ToImmutableArray());
|
||||
|
||||
var options = new ReachabilityAnalysisOptions { MaxTotalPaths = 3 };
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: should only return MaxTotalPaths paths
|
||||
Assert.Equal(3, result.Paths.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify MaxDepth limit is enforced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithOptions_RespectsMaxDepthLimit()
|
||||
{
|
||||
// Arrange: create a chain of 10 nodes
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var nodeId = $"node:{i:D3}";
|
||||
var isEntry = i == 0;
|
||||
var isSink = i == 9;
|
||||
nodes.Add(new CallGraphNode(nodeId, $"Node{i}", "f.cs", i, "app", Visibility.Public, isEntry, isEntry ? EntrypointType.HttpHandler : null, isSink, isSink ? StellaOps.Scanner.Reachability.SinkCategory.CmdExec : null));
|
||||
if (i > 0)
|
||||
{
|
||||
edges.Add(new CallGraphEdge($"node:{(i-1):D3}", nodeId, CallKind.Direct));
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray(),
|
||||
EntrypointIds: ["node:000"],
|
||||
SinkIds: ["node:009"]);
|
||||
|
||||
// With MaxDepth=5, the sink at depth 9 should not be reachable
|
||||
var options = new ReachabilityAnalysisOptions { MaxDepth = 5 };
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: sink should not be reachable due to depth limit
|
||||
Assert.Empty(result.ReachableSinkIds);
|
||||
Assert.Empty(result.Paths);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007A: Verify node IDs in paths are ordered from entrypoint to sink.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_PathNodeIds_AreOrderedFromEntrypointToSink()
|
||||
{
|
||||
var entry = "entry:start";
|
||||
var mid1 = "mid:step1";
|
||||
var mid2 = "mid:step2";
|
||||
var sink = "sink:end";
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid1, "Mid1", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(mid2, "Mid2", "f.cs", 3, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(sink, "Sink", "f.cs", 4, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, mid1, CallKind.Direct),
|
||||
new CallGraphEdge(mid1, mid2, CallKind.Direct),
|
||||
new CallGraphEdge(mid2, sink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [sink]);
|
||||
|
||||
var analyzer = new ReachabilityAnalyzer();
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: path should start with entry and end with sink
|
||||
Assert.Single(result.Paths);
|
||||
var path = result.Paths[0];
|
||||
Assert.Equal(4, path.NodeIds.Length);
|
||||
Assert.Equal(entry, path.NodeIds[0]); // First: entrypoint
|
||||
Assert.Equal(mid1, path.NodeIds[1]);
|
||||
Assert.Equal(mid2, path.NodeIds[2]);
|
||||
Assert.Equal(sink, path.NodeIds[3]); // Last: sink
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007B: Verify ExplicitSinks option allows targeting specific sinks not in snapshot.SinkIds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithExplicitSinks_FindsPathsToSpecifiedSinksOnly()
|
||||
{
|
||||
// Arrange: graph with 3 reachable nodes, only 1 is in snapshot.SinkIds
|
||||
var entry = "entry:start";
|
||||
var mid = "mid:step";
|
||||
var snapshotSink = "sink:in-snapshot";
|
||||
var explicitSink = "sink:explicit-target"; // Not in snapshot.SinkIds
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(mid, "Mid", "f.cs", 2, "app", Visibility.Public, false, null, false, null),
|
||||
new CallGraphNode(snapshotSink, "SnapshotSink", "f.cs", 3, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
new CallGraphNode(explicitSink, "ExplicitSink", "f.cs", 4, "lib", Visibility.Public, false, null, false, null), // Not marked as sink
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, mid, CallKind.Direct),
|
||||
new CallGraphEdge(mid, snapshotSink, CallKind.Direct),
|
||||
new CallGraphEdge(mid, explicitSink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [snapshotSink]); // Only snapshotSink is in the default sink list
|
||||
|
||||
// Use ExplicitSinks to target the non-sink node as if it were a trigger method
|
||||
var options = new ReachabilityAnalysisOptions
|
||||
{
|
||||
ExplicitSinks = [explicitSink]
|
||||
};
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: should find path to explicit sink only, not the snapshot sink
|
||||
Assert.Single(result.ReachableSinkIds);
|
||||
Assert.Equal(explicitSink, result.ReachableSinkIds[0]);
|
||||
Assert.Single(result.Paths);
|
||||
Assert.Equal(explicitSink, result.Paths[0].SinkId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-007B: Verify ExplicitSinks with empty array falls back to snapshot sinks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Analyze_WithEmptyExplicitSinks_UsesSnapshotSinks()
|
||||
{
|
||||
var entry = "entry:start";
|
||||
var sink = "sink:default";
|
||||
|
||||
var snapshot = new CallGraphSnapshot(
|
||||
ScanId: "scan-1",
|
||||
GraphDigest: "sha256:test",
|
||||
Language: "dotnet",
|
||||
ExtractedAt: DateTimeOffset.UtcNow,
|
||||
Nodes:
|
||||
[
|
||||
new CallGraphNode(entry, "Entry", "f.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
|
||||
new CallGraphNode(sink, "Sink", "f.cs", 2, "lib", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
|
||||
],
|
||||
Edges:
|
||||
[
|
||||
new CallGraphEdge(entry, sink, CallKind.Direct),
|
||||
],
|
||||
EntrypointIds: [entry],
|
||||
SinkIds: [sink]);
|
||||
|
||||
// Empty explicit sinks should fall back to snapshot sinks
|
||||
var options = new ReachabilityAnalysisOptions
|
||||
{
|
||||
ExplicitSinks = ImmutableArray<string>.Empty
|
||||
};
|
||||
var analyzer = new ReachabilityAnalyzer(null, options);
|
||||
|
||||
// Act
|
||||
var result = analyzer.Analyze(snapshot);
|
||||
|
||||
// Assert: should use snapshot sinks
|
||||
Assert.Single(result.ReachableSinkIds);
|
||||
Assert.Equal(sink, result.ReachableSinkIds[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Tests;
|
||||
|
||||
public class ScanManifestTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeHash_SameManifest_ProducesSameHash()
|
||||
{
|
||||
var manifest1 = CreateSampleManifest();
|
||||
var manifest2 = CreateSampleManifest();
|
||||
|
||||
var hash1 = manifest1.ComputeHash();
|
||||
var hash2 = manifest2.ComputeHash();
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.StartsWith("sha256:", hash1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_DifferentSeed_ProducesDifferentHash()
|
||||
{
|
||||
var seed1 = new byte[32];
|
||||
var seed2 = new byte[32];
|
||||
seed1[0] = 1;
|
||||
seed2[0] = 2;
|
||||
|
||||
var manifest1 = CreateSampleManifest(seed: seed1);
|
||||
var manifest2 = CreateSampleManifest(seed: seed2);
|
||||
|
||||
Assert.NotEqual(manifest1.ComputeHash(), manifest2.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_DifferentArtifactDigest_ProducesDifferentHash()
|
||||
{
|
||||
var manifest1 = CreateSampleManifest(artifactDigest: "sha256:abc123");
|
||||
var manifest2 = CreateSampleManifest(artifactDigest: "sha256:def456");
|
||||
|
||||
Assert.NotEqual(manifest1.ComputeHash(), manifest2.ComputeHash());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_HashIsLowercaseHex()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
var hash = manifest.ComputeHash();
|
||||
|
||||
// Remove sha256: prefix and check format
|
||||
var hexPart = hash["sha256:".Length..];
|
||||
Assert.Matches(@"^[0-9a-f]{64}$", hexPart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
var json = manifest.ToJson();
|
||||
var deserialized = ScanManifest.FromJson(json);
|
||||
|
||||
Assert.Equal(manifest.ScanId, deserialized.ScanId);
|
||||
Assert.Equal(manifest.ArtifactDigest, deserialized.ArtifactDigest);
|
||||
Assert.Equal(manifest.ArtifactPurl, deserialized.ArtifactPurl);
|
||||
Assert.Equal(manifest.ScannerVersion, deserialized.ScannerVersion);
|
||||
Assert.Equal(manifest.WorkerVersion, deserialized.WorkerVersion);
|
||||
Assert.Equal(manifest.ConcelierSnapshotHash, deserialized.ConcelierSnapshotHash);
|
||||
Assert.Equal(manifest.ExcititorSnapshotHash, deserialized.ExcititorSnapshotHash);
|
||||
Assert.Equal(manifest.LatticePolicyHash, deserialized.LatticePolicyHash);
|
||||
Assert.Equal(manifest.Deterministic, deserialized.Deterministic);
|
||||
Assert.Equal(manifest.Seed, deserialized.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_JsonPropertyNames_AreCamelCase()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
var json = manifest.ToJson();
|
||||
|
||||
Assert.Contains("\"scanId\":", json);
|
||||
Assert.Contains("\"createdAtUtc\":", json);
|
||||
Assert.Contains("\"artifactDigest\":", json);
|
||||
Assert.Contains("\"scannerVersion\":", json);
|
||||
Assert.Contains("\"concelierSnapshotHash\":", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_ProducesDeterministicOutput()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
|
||||
var json1 = manifest.ToCanonicalJson();
|
||||
var json2 = manifest.ToCanonicalJson();
|
||||
|
||||
Assert.Equal(json1, json2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_CreatesValidManifest()
|
||||
{
|
||||
var seed = new byte[32];
|
||||
seed[0] = 0x42;
|
||||
|
||||
var manifest = ScanManifest.CreateBuilder("scan-001", "sha256:abc123")
|
||||
.WithArtifactPurl("pkg:oci/myapp@sha256:abc123")
|
||||
.WithScannerVersion("2.0.0")
|
||||
.WithWorkerVersion("2.0.0")
|
||||
.WithConcelierSnapshot("sha256:feed123")
|
||||
.WithExcititorSnapshot("sha256:vex456")
|
||||
.WithLatticePolicyHash("sha256:policy789")
|
||||
.WithDeterministic(true)
|
||||
.WithSeed(seed)
|
||||
.WithKnob("maxDepth", "10")
|
||||
.Build();
|
||||
|
||||
Assert.Equal("scan-001", manifest.ScanId);
|
||||
Assert.Equal("sha256:abc123", manifest.ArtifactDigest);
|
||||
Assert.Equal("pkg:oci/myapp@sha256:abc123", manifest.ArtifactPurl);
|
||||
Assert.Equal("2.0.0", manifest.ScannerVersion);
|
||||
Assert.Equal("sha256:feed123", manifest.ConcelierSnapshotHash);
|
||||
Assert.True(manifest.Deterministic);
|
||||
Assert.Equal((byte)0x42, manifest.Seed[0]);
|
||||
Assert.Equal("10", manifest.Knobs["maxDepth"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithKnobs_MergesMultipleKnobs()
|
||||
{
|
||||
var manifest = ScanManifest.CreateBuilder("scan-001", "sha256:abc123")
|
||||
.WithKnob("key1", "value1")
|
||||
.WithKnobs(new Dictionary<string, string> { ["key2"] = "value2", ["key3"] = "value3" })
|
||||
.WithKnob("key4", "value4")
|
||||
.WithSeed(new byte[32])
|
||||
.Build();
|
||||
|
||||
Assert.Equal(4, manifest.Knobs.Count);
|
||||
Assert.Equal("value1", manifest.Knobs["key1"]);
|
||||
Assert.Equal("value2", manifest.Knobs["key2"]);
|
||||
Assert.Equal("value3", manifest.Knobs["key3"]);
|
||||
Assert.Equal("value4", manifest.Knobs["key4"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_SeedMustBe32Bytes()
|
||||
{
|
||||
var builder = ScanManifest.CreateBuilder("scan-001", "sha256:abc123");
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => builder.WithSeed(new byte[16]));
|
||||
Assert.Contains("32 bytes", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_WithExpression_CreatesModifiedCopy()
|
||||
{
|
||||
var original = CreateSampleManifest();
|
||||
var modified = original with { Deterministic = false };
|
||||
|
||||
Assert.True(original.Deterministic);
|
||||
Assert.False(modified.Deterministic);
|
||||
Assert.Equal(original.ScanId, modified.ScanId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Indented_FormatsOutput()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
var json = manifest.ToJson(indented: true);
|
||||
|
||||
Assert.Contains("\n", json);
|
||||
Assert.Contains(" ", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_NotIndented_CompactOutput()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
var json = manifest.ToJson(indented: false);
|
||||
|
||||
Assert.DoesNotContain("\n", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnobsCollection_IsImmutable()
|
||||
{
|
||||
var manifest = CreateSampleManifest();
|
||||
|
||||
// Knobs is IReadOnlyDictionary - cannot be modified
|
||||
Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(manifest.Knobs);
|
||||
}
|
||||
|
||||
private static ScanManifest CreateSampleManifest(
|
||||
string scanId = "scan-001",
|
||||
string artifactDigest = "sha256:abc123",
|
||||
byte[]? seed = null)
|
||||
{
|
||||
seed ??= new byte[32];
|
||||
|
||||
return ScanManifest.CreateBuilder(scanId, artifactDigest)
|
||||
.WithCreatedAt(DateTimeOffset.Parse("2025-12-17T12:00:00Z"))
|
||||
.WithArtifactPurl("pkg:oci/myapp@sha256:abc123")
|
||||
.WithScannerVersion("1.0.0")
|
||||
.WithWorkerVersion("1.0.0")
|
||||
.WithConcelierSnapshot("sha256:feed123")
|
||||
.WithExcititorSnapshot("sha256:vex456")
|
||||
.WithLatticePolicyHash("sha256:policy789")
|
||||
.WithDeterministic(true)
|
||||
.WithSeed(seed)
|
||||
.WithKnob("maxDepth", "10")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -384,4 +384,150 @@ public class PathWitnessBuilderTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildFromAnalyzerAsync Tests (WIT-008)
|
||||
|
||||
/// <summary>
|
||||
/// WIT-008: Test that BuildFromAnalyzerAsync generates witnesses from pre-computed paths.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BuildFromAnalyzerAsync_GeneratesWitnessesFromPaths()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var paths = new List<AnalyzerPathData>
|
||||
{
|
||||
new("entry:001", "sink:001",
|
||||
System.Collections.Immutable.ImmutableArray.Create("entry:001", "mid:001", "sink:001"))
|
||||
};
|
||||
|
||||
var nodeMetadata = new Dictionary<string, AnalyzerNodeData>
|
||||
{
|
||||
["entry:001"] = new("EntryMethod", "src/Entry.cs", 10, "http"),
|
||||
["mid:001"] = new("MiddleMethod", "src/Middle.cs", 20, null),
|
||||
["sink:001"] = new("SinkMethod", "src/Sink.cs", 30, null)
|
||||
};
|
||||
|
||||
var request = new AnalyzerWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-99999",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
SinkType = "sql_injection",
|
||||
GraphDigest = "blake3:graph123",
|
||||
Paths = paths,
|
||||
NodeMetadata = nodeMetadata,
|
||||
BuildId = "build:xyz"
|
||||
};
|
||||
|
||||
// Act
|
||||
var witnesses = new List<PathWitness>();
|
||||
await foreach (var witness in builder.BuildFromAnalyzerAsync(request))
|
||||
{
|
||||
witnesses.Add(witness);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(witnesses);
|
||||
var w = witnesses[0];
|
||||
Assert.Equal("CVE-2024-99999", w.Vuln.Id);
|
||||
Assert.Equal("entry:001", w.Entrypoint.SymbolId);
|
||||
Assert.Equal("sink:001", w.Sink.SymbolId);
|
||||
Assert.Equal(3, w.Path.Count);
|
||||
Assert.Equal("EntryMethod", w.Path[0].Symbol);
|
||||
Assert.Equal("MiddleMethod", w.Path[1].Symbol);
|
||||
Assert.Equal("SinkMethod", w.Path[2].Symbol);
|
||||
Assert.NotEmpty(w.WitnessId);
|
||||
Assert.StartsWith("wit:", w.WitnessId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-008: Test that BuildFromAnalyzerAsync yields empty when no paths provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BuildFromAnalyzerAsync_YieldsEmpty_WhenNoPaths()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var request = new AnalyzerWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-99999",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
SinkType = "sql_injection",
|
||||
GraphDigest = "blake3:graph123",
|
||||
Paths = new List<AnalyzerPathData>(),
|
||||
NodeMetadata = new Dictionary<string, AnalyzerNodeData>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var witnesses = new List<PathWitness>();
|
||||
await foreach (var witness in builder.BuildFromAnalyzerAsync(request))
|
||||
{
|
||||
witnesses.Add(witness);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Empty(witnesses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WIT-008: Test that missing node metadata is handled gracefully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BuildFromAnalyzerAsync_HandlesMissingNodeMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new PathWitnessBuilder(_cryptoHash, _timeProvider);
|
||||
|
||||
var paths = new List<AnalyzerPathData>
|
||||
{
|
||||
new("entry:001", "sink:001",
|
||||
System.Collections.Immutable.ImmutableArray.Create("entry:001", "unknown:002", "sink:001"))
|
||||
};
|
||||
|
||||
// Only entry and sink have metadata, unknown:002 doesn't
|
||||
var nodeMetadata = new Dictionary<string, AnalyzerNodeData>
|
||||
{
|
||||
["entry:001"] = new("EntryMethod", "src/Entry.cs", 10, "http"),
|
||||
["sink:001"] = new("SinkMethod", "src/Sink.cs", 30, null)
|
||||
};
|
||||
|
||||
var request = new AnalyzerWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-99999",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
SinkType = "sql_injection",
|
||||
GraphDigest = "blake3:graph123",
|
||||
Paths = paths,
|
||||
NodeMetadata = nodeMetadata
|
||||
};
|
||||
|
||||
// Act
|
||||
var witnesses = new List<PathWitness>();
|
||||
await foreach (var witness in builder.BuildFromAnalyzerAsync(request))
|
||||
{
|
||||
witnesses.Add(witness);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(witnesses);
|
||||
var w = witnesses[0];
|
||||
Assert.Equal(3, w.Path.Count);
|
||||
// Unknown node should use its ID as symbol
|
||||
Assert.Equal("unknown:002", w.Path[1].Symbol);
|
||||
Assert.Equal("unknown:002", w.Path[1].SymbolId);
|
||||
Assert.Null(w.Path[1].File);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityCacheTests.cs
|
||||
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-016, CACHE-017)
|
||||
// Description: Unit tests for reachability cache components.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Cache;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class GraphDeltaComputerTests
|
||||
{
|
||||
private readonly GraphDeltaComputer _computer;
|
||||
|
||||
public GraphDeltaComputerTests()
|
||||
{
|
||||
_computer = new GraphDeltaComputer(NullLogger<GraphDeltaComputer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_SameHash_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
var graph2 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_AddedNode_ReturnsCorrectDelta()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeTrue();
|
||||
delta.AddedNodes.Should().Contain("C");
|
||||
delta.RemovedNodes.Should().BeEmpty();
|
||||
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
|
||||
delta.AffectedMethodKeys.Should().Contain("C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_RemovedNode_ReturnsCorrectDelta()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
|
||||
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B" }, new[] { ("A", "B") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeTrue();
|
||||
delta.RemovedNodes.Should().Contain("C");
|
||||
delta.AddedNodes.Should().BeEmpty();
|
||||
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_EdgeChange_DetectsAffectedMethods()
|
||||
{
|
||||
// Arrange
|
||||
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B") });
|
||||
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "C") });
|
||||
|
||||
// Act
|
||||
var delta = await _computer.ComputeDeltaAsync(graph1, graph2);
|
||||
|
||||
// Assert
|
||||
delta.HasChanges.Should().BeTrue();
|
||||
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "C");
|
||||
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "B");
|
||||
delta.AffectedMethodKeys.Should().Contain(new[] { "A", "B", "C" });
|
||||
}
|
||||
|
||||
private sealed class TestGraphSnapshot : IGraphSnapshot
|
||||
{
|
||||
public string Hash { get; }
|
||||
public IReadOnlySet<string> NodeKeys { get; }
|
||||
public IReadOnlyList<GraphEdge> Edges { get; }
|
||||
public IReadOnlySet<string> EntryPoints { get; }
|
||||
|
||||
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
|
||||
{
|
||||
Hash = hash;
|
||||
NodeKeys = nodes.ToHashSet();
|
||||
Edges = edges.Select(e => new GraphEdge(e.Item1, e.Item2)).ToList();
|
||||
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ImpactSetCalculatorTests
|
||||
{
|
||||
private readonly ImpactSetCalculator _calculator;
|
||||
|
||||
public ImpactSetCalculatorTests()
|
||||
{
|
||||
_calculator = new ImpactSetCalculator(NullLogger<ImpactSetCalculator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateImpactAsync_NoDelta_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var delta = GraphDelta.Empty;
|
||||
var graph = new TestGraphSnapshot("hash1", new[] { "Entry", "A", "B" }, new[] { ("Entry", "A"), ("A", "B") });
|
||||
|
||||
// Act
|
||||
var impact = await _calculator.CalculateImpactAsync(delta, graph);
|
||||
|
||||
// Assert
|
||||
impact.RequiresFullRecompute.Should().BeFalse();
|
||||
impact.AffectedEntryPoints.Should().BeEmpty();
|
||||
impact.SavingsRatio.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateImpactAsync_ChangeInPath_IdentifiesAffectedEntry()
|
||||
{
|
||||
// Arrange
|
||||
var delta = new GraphDelta
|
||||
{
|
||||
AddedNodes = new HashSet<string> { "C" },
|
||||
AddedEdges = new List<GraphEdge> { new("B", "C") },
|
||||
AffectedMethodKeys = new HashSet<string> { "B", "C" }
|
||||
};
|
||||
|
||||
var graph = new TestGraphSnapshot(
|
||||
"hash2",
|
||||
new[] { "Entry", "A", "B", "C" },
|
||||
new[] { ("Entry", "A"), ("A", "B"), ("B", "C") },
|
||||
new[] { "Entry" });
|
||||
|
||||
// Act
|
||||
var impact = await _calculator.CalculateImpactAsync(delta, graph);
|
||||
|
||||
// Assert
|
||||
impact.RequiresFullRecompute.Should().BeFalse();
|
||||
impact.AffectedEntryPoints.Should().Contain("Entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CalculateImpactAsync_ManyAffected_TriggersFullRecompute()
|
||||
{
|
||||
// Arrange - More than 30% affected
|
||||
var delta = new GraphDelta
|
||||
{
|
||||
AffectedMethodKeys = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" }
|
||||
};
|
||||
|
||||
var graph = new TestGraphSnapshot(
|
||||
"hash2",
|
||||
new[] { "Entry1", "Entry2", "Entry3", "Entry4", "Sink" },
|
||||
new[] { ("Entry1", "Sink"), ("Entry2", "Sink"), ("Entry3", "Sink"), ("Entry4", "Sink") },
|
||||
new[] { "Entry1", "Entry2", "Entry3", "Entry4" });
|
||||
|
||||
// Act
|
||||
var impact = await _calculator.CalculateImpactAsync(delta, graph);
|
||||
|
||||
// Assert - All 4 entries affected = 100% > 30% threshold
|
||||
impact.RequiresFullRecompute.Should().BeTrue();
|
||||
}
|
||||
|
||||
private sealed class TestGraphSnapshot : IGraphSnapshot
|
||||
{
|
||||
public string Hash { get; }
|
||||
public IReadOnlySet<string> NodeKeys { get; }
|
||||
public IReadOnlyList<GraphEdge> Edges { get; }
|
||||
public IReadOnlySet<string> EntryPoints { get; }
|
||||
|
||||
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
|
||||
{
|
||||
Hash = hash;
|
||||
NodeKeys = nodes.ToHashSet();
|
||||
Edges = edges.Select(e => new GraphEdge(e.Item1, e.Item2)).ToList();
|
||||
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StateFlipDetectorTests
|
||||
{
|
||||
private readonly StateFlipDetector _detector;
|
||||
|
||||
public StateFlipDetectorTests()
|
||||
{
|
||||
_detector = new StateFlipDetector(NullLogger<StateFlipDetector>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_NoChanges_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeFalse();
|
||||
result.NewRiskCount.Should().Be(0);
|
||||
result.MitigatedCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_BecameReachable_ReturnsNewRisk()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.NewRiskCount.Should().Be(1);
|
||||
result.MitigatedCount.Should().Be(0);
|
||||
result.NewlyReachable.Should().ContainSingle()
|
||||
.Which.FlipType.Should().Be(StateFlipType.BecameReachable);
|
||||
result.ShouldBlockPr.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_BecameUnreachable_ReturnsMitigated()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.NewRiskCount.Should().Be(0);
|
||||
result.MitigatedCount.Should().Be(1);
|
||||
result.NewlyUnreachable.Should().ContainSingle()
|
||||
.Which.FlipType.Should().Be(StateFlipType.BecameUnreachable);
|
||||
result.ShouldBlockPr.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_NewReachablePair_ReturnsNewRisk()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>();
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.NewRiskCount.Should().Be(1);
|
||||
result.ShouldBlockPr.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_RemovedReachablePair_ReturnsMitigated()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>();
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.HasFlips.Should().BeTrue();
|
||||
result.MitigatedCount.Should().Be(1);
|
||||
result.ShouldBlockPr.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectFlipsAsync_NetChange_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var previous = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
||||
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
var current = new List<ReachablePairResult>
|
||||
{
|
||||
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
||||
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
|
||||
new() { EntryMethodKey = "E3", SinkMethodKey = "S3", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _detector.DetectFlipsAsync(previous, current);
|
||||
|
||||
// Assert
|
||||
result.NewRiskCount.Should().Be(2); // E2->S2 became reachable, E3->S3 new
|
||||
result.MitigatedCount.Should().Be(1); // E1->S1 became unreachable
|
||||
result.NetChange.Should().Be(1); // +2 - 1 = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SignedWitnessGenerator"/>.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-009)
|
||||
/// </summary>
|
||||
public class SignedWitnessGeneratorTests
|
||||
{
|
||||
private readonly IPathWitnessBuilder _builder;
|
||||
private readonly IWitnessDsseSigner _signer;
|
||||
private readonly SignedWitnessGenerator _generator;
|
||||
private readonly EnvelopeKey _testKey;
|
||||
|
||||
public SignedWitnessGeneratorTests()
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
_builder = new PathWitnessBuilder(cryptoHash, TimeProvider.System);
|
||||
_signer = new WitnessDsseSigner();
|
||||
_generator = new SignedWitnessGenerator(_builder, _signer);
|
||||
_testKey = CreateTestKey();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSignedWitnessAsync_ReturnsNull_WhenNoPathExists()
|
||||
{
|
||||
// Arrange - Request with no valid path (unreachable sink)
|
||||
var graph = CreateSimpleGraph();
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:unreachable", // Not in graph
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateSignedWitnessAsync(request, _testKey);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSignedWitnessAsync_ReturnsSignedResult_WhenPathExists()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateSignedWitnessAsync(request, _testKey);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Witness);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.NotEmpty(result.PayloadBytes!);
|
||||
Assert.Equal(WitnessSchema.DssePayloadType, result.Envelope.PayloadType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateSignedWitnessesFromAnalyzerAsync_GeneratesSignedEnvelopes()
|
||||
{
|
||||
// Arrange
|
||||
var paths = new List<AnalyzerPathData>
|
||||
{
|
||||
new("entry:001", "sink:001",
|
||||
ImmutableArray.Create("entry:001", "mid:001", "sink:001")),
|
||||
new("entry:002", "sink:002",
|
||||
ImmutableArray.Create("entry:002", "sink:002"))
|
||||
};
|
||||
|
||||
var nodeMetadata = new Dictionary<string, AnalyzerNodeData>
|
||||
{
|
||||
["entry:001"] = new("EntryMethod1", "src/Entry.cs", 10, "http"),
|
||||
["mid:001"] = new("MiddleMethod", "src/Middle.cs", 20, null),
|
||||
["sink:001"] = new("SinkMethod1", "src/Sink.cs", 30, null),
|
||||
["entry:002"] = new("EntryMethod2", "src/Entry2.cs", 40, "grpc"),
|
||||
["sink:002"] = new("SinkMethod2", "src/Sink2.cs", 50, null)
|
||||
};
|
||||
|
||||
var request = new AnalyzerWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-0000",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
SinkType = "deserialization",
|
||||
GraphDigest = "blake3:graph123",
|
||||
Paths = paths,
|
||||
NodeMetadata = nodeMetadata
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = new List<SignedWitnessResult>();
|
||||
await foreach (var result in _generator.GenerateSignedWitnessesFromAnalyzerAsync(request, _testKey))
|
||||
{
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.IsSuccess));
|
||||
Assert.All(results, r => Assert.NotNull(r.Envelope));
|
||||
Assert.Equal("entry:001", results[0].Witness!.Entrypoint.SymbolId);
|
||||
Assert.Equal("entry:002", results[1].Witness!.Entrypoint.SymbolId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratedEnvelope_CanBeVerified()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateSimpleGraph();
|
||||
var request = new PathWitnessRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom123",
|
||||
ComponentPurl = "pkg:nuget/Test@1.0.0",
|
||||
VulnId = "CVE-2024-12345",
|
||||
VulnSource = "NVD",
|
||||
AffectedRange = "<=1.0.0",
|
||||
EntrypointSymbolId = "sym:entry",
|
||||
EntrypointKind = "http",
|
||||
EntrypointName = "GET /api/test",
|
||||
SinkSymbolId = "sym:sink",
|
||||
SinkType = "deserialization",
|
||||
CallGraph = graph,
|
||||
CallgraphDigest = "blake3:graph123"
|
||||
};
|
||||
|
||||
var (_, publicKey) = GetTestKeyPair();
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
|
||||
// Act
|
||||
var result = await _generator.GenerateSignedWitnessAsync(request, _testKey);
|
||||
|
||||
// Assert - Verify the envelope
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
var verifyResult = _signer.VerifyWitness(result.Envelope!, verifyKey);
|
||||
Assert.True(verifyResult.IsSuccess);
|
||||
Assert.Equal(result.Witness!.WitnessId, verifyResult.Witness!.WitnessId);
|
||||
}
|
||||
|
||||
private static RichGraph CreateSimpleGraph()
|
||||
{
|
||||
var nodes = new List<RichGraphNode>
|
||||
{
|
||||
new("n1", "sym:entry", null, null, "dotnet", "method", "Entry", null, null, null, null),
|
||||
new("n2", "sym:middle", null, null, "dotnet", "method", "Middle", null, null, null, null),
|
||||
new("n3", "sym:sink", null, null, "dotnet", "method", "Sink", null, null, null, null)
|
||||
};
|
||||
|
||||
var edges = new List<RichGraphEdge>
|
||||
{
|
||||
new("n1", "n2", "call", null, null, null, 1.0, null),
|
||||
new("n2", "n3", "call", null, null, null, 1.0, null)
|
||||
};
|
||||
|
||||
var roots = new List<RichGraphRoot>
|
||||
{
|
||||
new("n1", "http", "/api/test")
|
||||
};
|
||||
|
||||
return new RichGraph(
|
||||
nodes,
|
||||
edges,
|
||||
roots,
|
||||
new RichGraphAnalyzer("test", "1.0.0", null));
|
||||
}
|
||||
|
||||
private static EnvelopeKey CreateTestKey()
|
||||
{
|
||||
var (privateKey, publicKey) = GetTestKeyPair();
|
||||
return EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
}
|
||||
|
||||
private static (byte[] privateKey, byte[] publicKey) GetTestKeyPair()
|
||||
{
|
||||
var generator = new Ed25519KeyPairGenerator();
|
||||
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
|
||||
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
|
||||
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
|
||||
|
||||
var privateKey = new byte[64];
|
||||
privateParams.Encode(privateKey, 0);
|
||||
var publicKey = publicParams.GetEncoded();
|
||||
Array.Copy(publicKey, 0, privateKey, 32, 32);
|
||||
|
||||
return (privateKey, publicKey);
|
||||
}
|
||||
|
||||
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
|
||||
{
|
||||
private byte _value = 0x42;
|
||||
|
||||
public void AddSeedMaterial(byte[] seed) { }
|
||||
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
|
||||
public void AddSeedMaterial(long seed) { }
|
||||
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
|
||||
public void NextBytes(byte[] bytes, int start, int len)
|
||||
{
|
||||
for (int i = start; i < start + len; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
public void NextBytes(Span<byte> bytes)
|
||||
{
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SurfaceQueryServiceTests.cs
|
||||
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-012)
|
||||
// Description: Unit tests for SurfaceQueryService.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Reachability.Surfaces;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public sealed class SurfaceQueryServiceTests : IDisposable
|
||||
{
|
||||
private readonly FakeSurfaceRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<SurfaceQueryService> _logger;
|
||||
private readonly SurfaceQueryService _service;
|
||||
|
||||
public SurfaceQueryServiceTests()
|
||||
{
|
||||
_repository = new FakeSurfaceRepository();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = NullLogger<SurfaceQueryService>.Instance;
|
||||
_service = new SurfaceQueryService(
|
||||
_repository,
|
||||
_cache,
|
||||
_logger,
|
||||
new SurfaceQueryOptions { EnableCaching = true });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_WhenSurfaceFound_ReturnsFoundResult()
|
||||
{
|
||||
// Arrange
|
||||
var surfaceId = Guid.NewGuid();
|
||||
var cveId = "CVE-2023-1234";
|
||||
var packageName = "Newtonsoft.Json";
|
||||
var version = "12.0.1";
|
||||
var computedAt = DateTimeOffset.UtcNow.AddHours(-1);
|
||||
|
||||
_repository.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId,
|
||||
CveId = cveId,
|
||||
Ecosystem = "nuget",
|
||||
PackageName = packageName,
|
||||
VulnVersion = version,
|
||||
FixedVersion = "12.0.2",
|
||||
ComputedAt = computedAt,
|
||||
ChangedMethodCount = 3,
|
||||
TriggerCount = 5
|
||||
});
|
||||
|
||||
_repository.AddTriggers(surfaceId, new List<TriggerMethodInfo>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MethodKey = "Newtonsoft.Json.JsonConvert::DeserializeObject",
|
||||
MethodName = "DeserializeObject",
|
||||
DeclaringType = "JsonConvert",
|
||||
SinkCount = 2,
|
||||
ShortestPathLength = 1
|
||||
}
|
||||
});
|
||||
|
||||
_repository.AddSinks(surfaceId, new List<string> { "Newtonsoft.Json.Internal::Vulnerable" });
|
||||
|
||||
var request = new SurfaceQueryRequest
|
||||
{
|
||||
CveId = cveId,
|
||||
Ecosystem = "nuget",
|
||||
PackageName = packageName,
|
||||
Version = version
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.QueryAsync(request);
|
||||
|
||||
// Assert
|
||||
result.SurfaceFound.Should().BeTrue();
|
||||
result.Source.Should().Be(SinkSource.Surface);
|
||||
result.SurfaceId.Should().Be(surfaceId);
|
||||
result.Triggers.Should().HaveCount(1);
|
||||
result.Triggers[0].MethodName.Should().Be("DeserializeObject");
|
||||
result.ComputedAt.Should().Be(computedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_WhenSurfaceNotFound_ReturnsFallbackResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new SurfaceQueryRequest
|
||||
{
|
||||
CveId = "CVE-2023-9999",
|
||||
Ecosystem = "npm",
|
||||
PackageName = "unknown-package",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.QueryAsync(request);
|
||||
|
||||
// Assert
|
||||
result.SurfaceFound.Should().BeFalse();
|
||||
result.Source.Should().Be(SinkSource.FallbackAll);
|
||||
result.SurfaceId.Should().BeNull();
|
||||
result.Triggers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_CachesResult_ReturnsFromCacheOnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var surfaceId = Guid.NewGuid();
|
||||
_repository.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId,
|
||||
CveId = "CVE-2023-1234",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Test.Package",
|
||||
VulnVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var request = new SurfaceQueryRequest
|
||||
{
|
||||
CveId = "CVE-2023-1234",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Test.Package",
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await _service.QueryAsync(request);
|
||||
var result2 = await _service.QueryAsync(request);
|
||||
|
||||
// Assert
|
||||
result1.SurfaceFound.Should().BeTrue();
|
||||
result2.SurfaceFound.Should().BeTrue();
|
||||
|
||||
// Repository should only be called once due to caching
|
||||
_repository.GetSurfaceCallCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryBulkAsync_QueriesMultipleVulnerabilities()
|
||||
{
|
||||
// Arrange
|
||||
var surfaceId1 = Guid.NewGuid();
|
||||
|
||||
_repository.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = surfaceId1,
|
||||
CveId = "CVE-2023-0001",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Package1",
|
||||
VulnVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var requests = new List<SurfaceQueryRequest>
|
||||
{
|
||||
new() { CveId = "CVE-2023-0001", Ecosystem = "nuget", PackageName = "Package1", Version = "1.0.0" },
|
||||
new() { CveId = "CVE-2023-0002", Ecosystem = "nuget", PackageName = "Package2", Version = "2.0.0" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = await _service.QueryBulkAsync(requests);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(2);
|
||||
|
||||
var key1 = "CVE-2023-0001|nuget|Package1|1.0.0";
|
||||
var key2 = "CVE-2023-0002|nuget|Package2|2.0.0";
|
||||
|
||||
results[key1].SurfaceFound.Should().BeTrue();
|
||||
results[key2].SurfaceFound.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_ReturnsTrueWhenSurfaceExists()
|
||||
{
|
||||
// Arrange
|
||||
_repository.AddSurface(new SurfaceInfo
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CveId = "CVE-2023-1234",
|
||||
Ecosystem = "nuget",
|
||||
PackageName = "Package",
|
||||
VulnVersion = "1.0.0",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// Act
|
||||
var exists = await _service.ExistsAsync("CVE-2023-1234", "nuget", "Package", "1.0.0");
|
||||
|
||||
// Assert
|
||||
exists.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_ReturnsFalseWhenSurfaceDoesNotExist()
|
||||
{
|
||||
// Act
|
||||
var exists = await _service.ExistsAsync("CVE-2023-9999", "npm", "unknown", "1.0.0");
|
||||
|
||||
// Assert
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake implementation of ISurfaceRepository for testing.
|
||||
/// </summary>
|
||||
private sealed class FakeSurfaceRepository : ISurfaceRepository
|
||||
{
|
||||
private readonly Dictionary<string, SurfaceInfo> _surfaces = new();
|
||||
private readonly Dictionary<Guid, List<TriggerMethodInfo>> _triggers = new();
|
||||
private readonly Dictionary<Guid, List<string>> _sinks = new();
|
||||
public int GetSurfaceCallCount { get; private set; }
|
||||
|
||||
public void AddSurface(SurfaceInfo surface)
|
||||
{
|
||||
var key = $"{surface.CveId}|{surface.Ecosystem}|{surface.PackageName}|{surface.VulnVersion}";
|
||||
_surfaces[key] = surface;
|
||||
}
|
||||
|
||||
public void AddTriggers(Guid surfaceId, List<TriggerMethodInfo> triggers)
|
||||
{
|
||||
_triggers[surfaceId] = triggers;
|
||||
}
|
||||
|
||||
public void AddSinks(Guid surfaceId, List<string> sinks)
|
||||
{
|
||||
_sinks[surfaceId] = sinks;
|
||||
}
|
||||
|
||||
public Task<SurfaceInfo?> GetSurfaceAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GetSurfaceCallCount++;
|
||||
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
|
||||
_surfaces.TryGetValue(key, out var surface);
|
||||
return Task.FromResult(surface);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(Guid surfaceId, int maxCount, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_triggers.TryGetValue(surfaceId, out var triggers))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(triggers);
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<TriggerMethodInfo>>(new List<TriggerMethodInfo>());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetSinksAsync(Guid surfaceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_sinks.TryGetValue(surfaceId, out var sinks))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(sinks);
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<string>>(new List<string>());
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string cveId, string ecosystem, string packageName, string version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = $"{cveId}|{ecosystem}|{packageName}|{version}";
|
||||
return Task.FromResult(_surfaces.ContainsKey(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Security;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Scanner.Reachability.Witnesses;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="WitnessDsseSigner"/>.
|
||||
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
|
||||
/// Golden fixture tests for DSSE sign/verify.
|
||||
/// </summary>
|
||||
public class WitnessDsseSignerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a deterministic Ed25519 key pair for testing.
|
||||
/// </summary>
|
||||
private static (byte[] privateKey, byte[] publicKey) CreateTestKeyPair()
|
||||
{
|
||||
// Use a fixed seed for deterministic tests
|
||||
var generator = new Ed25519KeyPairGenerator();
|
||||
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
|
||||
var keyPair = generator.GenerateKeyPair();
|
||||
|
||||
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
|
||||
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
|
||||
|
||||
// Ed25519 private key = 32-byte seed + 32-byte public key
|
||||
var privateKey = new byte[64];
|
||||
privateParams.Encode(privateKey, 0);
|
||||
var publicKey = publicParams.GetEncoded();
|
||||
|
||||
// Append public key to make 64-byte expanded form
|
||||
Array.Copy(publicKey, 0, privateKey, 32, 32);
|
||||
|
||||
return (privateKey, publicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignWitness_WithValidKey_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Act
|
||||
var result = signer.SignWitness(witness, key);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess, result.Error);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Equal(WitnessSchema.DssePayloadType, result.Envelope.PayloadType);
|
||||
Assert.Single(result.Envelope.Signatures);
|
||||
Assert.NotEmpty(result.PayloadBytes!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Sign the witness
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
Assert.True(signResult.IsSuccess, signResult.Error);
|
||||
|
||||
// Create public key for verification
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
|
||||
// Act
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.True(verifyResult.IsSuccess, verifyResult.Error);
|
||||
Assert.NotNull(verifyResult.Witness);
|
||||
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
|
||||
Assert.Equal(witness.Vuln.Id, verifyResult.Witness.Vuln.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyWitness_WithWrongKey_ReturnsFails()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Sign the witness
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
Assert.True(signResult.IsSuccess, signResult.Error);
|
||||
|
||||
// Create a different key for verification (different keyId)
|
||||
var generator = new Ed25519KeyPairGenerator();
|
||||
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
|
||||
var wrongKeyPair = generator.GenerateKeyPair();
|
||||
var wrongPublicKey = ((Ed25519PublicKeyParameters)wrongKeyPair.Public).GetEncoded();
|
||||
var wrongKey = EnvelopeKey.CreateEd25519Verifier(wrongPublicKey);
|
||||
|
||||
// Act - verify with wrong key (keyId won't match)
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, wrongKey);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsSuccess);
|
||||
Assert.Contains("No signature found for key ID", verifyResult.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignWitness_ProducesDeterministicPayload()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Act
|
||||
var result1 = signer.SignWitness(witness, key);
|
||||
var result2 = signer.SignWitness(witness, key);
|
||||
|
||||
// Assert: payloads should be identical (deterministic serialization)
|
||||
Assert.True(result1.IsSuccess);
|
||||
Assert.True(result2.IsSuccess);
|
||||
Assert.Equal(result1.PayloadBytes, result2.PayloadBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
Assert.True(signResult.IsSuccess);
|
||||
|
||||
// Create envelope with wrong payload type
|
||||
var wrongEnvelope = new DsseEnvelope(
|
||||
payloadType: "application/wrong-type",
|
||||
payload: signResult.Envelope!.Payload,
|
||||
signatures: signResult.Envelope.Signatures);
|
||||
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
|
||||
// Act
|
||||
var verifyResult = signer.VerifyWitness(wrongEnvelope, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsSuccess);
|
||||
Assert.Contains("Invalid payload type", verifyResult.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesAllWitnessFields()
|
||||
{
|
||||
// Arrange
|
||||
var witness = CreateTestWitness();
|
||||
var (privateKey, publicKey) = CreateTestKeyPair();
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
||||
var signer = new WitnessDsseSigner();
|
||||
|
||||
// Act
|
||||
var signResult = signer.SignWitness(witness, signingKey);
|
||||
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey);
|
||||
|
||||
// Assert
|
||||
Assert.True(signResult.IsSuccess);
|
||||
Assert.True(verifyResult.IsSuccess);
|
||||
|
||||
var roundTripped = verifyResult.Witness!;
|
||||
Assert.Equal(witness.WitnessSchema, roundTripped.WitnessSchema);
|
||||
Assert.Equal(witness.WitnessId, roundTripped.WitnessId);
|
||||
Assert.Equal(witness.Artifact.SbomDigest, roundTripped.Artifact.SbomDigest);
|
||||
Assert.Equal(witness.Artifact.ComponentPurl, roundTripped.Artifact.ComponentPurl);
|
||||
Assert.Equal(witness.Vuln.Id, roundTripped.Vuln.Id);
|
||||
Assert.Equal(witness.Vuln.Source, roundTripped.Vuln.Source);
|
||||
Assert.Equal(witness.Entrypoint.Kind, roundTripped.Entrypoint.Kind);
|
||||
Assert.Equal(witness.Entrypoint.Name, roundTripped.Entrypoint.Name);
|
||||
Assert.Equal(witness.Entrypoint.SymbolId, roundTripped.Entrypoint.SymbolId);
|
||||
Assert.Equal(witness.Sink.Symbol, roundTripped.Sink.Symbol);
|
||||
Assert.Equal(witness.Sink.SymbolId, roundTripped.Sink.SymbolId);
|
||||
Assert.Equal(witness.Sink.SinkType, roundTripped.Sink.SinkType);
|
||||
Assert.Equal(witness.Path.Count, roundTripped.Path.Count);
|
||||
Assert.Equal(witness.Evidence.CallgraphDigest, roundTripped.Evidence.CallgraphDigest);
|
||||
}
|
||||
|
||||
private static PathWitness CreateTestWitness()
|
||||
{
|
||||
return new PathWitness
|
||||
{
|
||||
WitnessId = "wit:sha256:abc123def456",
|
||||
Artifact = new WitnessArtifact
|
||||
{
|
||||
SbomDigest = "sha256:sbom123456",
|
||||
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3"
|
||||
},
|
||||
Vuln = new WitnessVuln
|
||||
{
|
||||
Id = "CVE-2024-12345",
|
||||
Source = "NVD",
|
||||
AffectedRange = "<=12.0.3"
|
||||
},
|
||||
Entrypoint = new WitnessEntrypoint
|
||||
{
|
||||
Kind = "http",
|
||||
Name = "GET /api/users",
|
||||
SymbolId = "sym:entry:001"
|
||||
},
|
||||
Path = new List<PathStep>
|
||||
{
|
||||
new PathStep
|
||||
{
|
||||
Symbol = "UserController.GetUsers",
|
||||
SymbolId = "sym:step:001",
|
||||
File = "Controllers/UserController.cs",
|
||||
Line = 42
|
||||
},
|
||||
new PathStep
|
||||
{
|
||||
Symbol = "JsonConvert.DeserializeObject",
|
||||
SymbolId = "sym:step:002",
|
||||
File = null,
|
||||
Line = null
|
||||
}
|
||||
},
|
||||
Sink = new WitnessSink
|
||||
{
|
||||
Symbol = "JsonConvert.DeserializeObject<T>",
|
||||
SymbolId = "sym:sink:001",
|
||||
SinkType = "deserialization"
|
||||
},
|
||||
Evidence = new WitnessEvidence
|
||||
{
|
||||
CallgraphDigest = "blake3:graph123456",
|
||||
SurfaceDigest = "sha256:surface789",
|
||||
BuildId = "build:xyz123"
|
||||
},
|
||||
ObservedAt = new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed random generator for deterministic key generation in tests.
|
||||
/// </summary>
|
||||
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
|
||||
{
|
||||
private byte _value = 0x42;
|
||||
|
||||
public void AddSeedMaterial(byte[] seed) { }
|
||||
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
|
||||
public void AddSeedMaterial(long seed) { }
|
||||
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
|
||||
public void NextBytes(byte[] bytes, int start, int len)
|
||||
{
|
||||
for (int i = start; i < start + len; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
public void NextBytes(Span<byte> bytes)
|
||||
{
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = _value++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftAttestationServiceTests.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-018
|
||||
// Description: Unit tests for DriftAttestationService.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Tests;
|
||||
|
||||
public sealed class DriftAttestationServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IOptionsMonitor<DriftAttestationOptions>> _optionsMock;
|
||||
private readonly DriftAttestationOptions _options;
|
||||
|
||||
public DriftAttestationServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
_options = new DriftAttestationOptions { Enabled = true, UseSignerService = false };
|
||||
_optionsMock = new Mock<IOptionsMonitor<DriftAttestationOptions>>();
|
||||
_optionsMock.Setup(x => x.CurrentValue).Returns(_options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Creates_Valid_Attestation()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.AttestationDigest.Should().StartWith("sha256:");
|
||||
result.EnvelopeJson.Should().NotBeNullOrEmpty();
|
||||
result.KeyId.Should().Be("local-dev-key");
|
||||
result.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Returns_Failure_When_Disabled()
|
||||
{
|
||||
// Arrange
|
||||
_options.Enabled = false;
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Throws_When_Request_Null()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => service.CreateAttestationAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Envelope_Contains_Correct_PayloadType()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result.EnvelopeJson.Should().Contain("application/vnd.in-toto+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Envelope_Contains_Signature()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonDocument.Parse(result.EnvelopeJson!);
|
||||
var signatures = envelope.RootElement.GetProperty("signatures");
|
||||
signatures.GetArrayLength().Should().Be(1);
|
||||
signatures[0].GetProperty("keyid").GetString().Should().Be("local-dev-key");
|
||||
signatures[0].GetProperty("sig").GetString().Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Statement_Contains_Predicate()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
var envelope = JsonDocument.Parse(result.EnvelopeJson!);
|
||||
var payloadBase64 = envelope.RootElement.GetProperty("payload").GetString();
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64!);
|
||||
var statement = JsonDocument.Parse(payloadBytes);
|
||||
|
||||
statement.RootElement.GetProperty("predicateType").GetString()
|
||||
.Should().Be("stellaops.dev/predicates/reachability-drift@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Predicate_Contains_Drift_Summary()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
var predicate = ExtractPredicate(result.EnvelopeJson!);
|
||||
|
||||
predicate.GetProperty("drift").GetProperty("newlyReachableCount").GetInt32().Should().Be(1);
|
||||
predicate.GetProperty("drift").GetProperty("newlyUnreachableCount").GetInt32().Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Predicate_Contains_Image_References()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
var predicate = ExtractPredicate(result.EnvelopeJson!);
|
||||
|
||||
predicate.GetProperty("baseImage").GetProperty("name").GetString()
|
||||
.Should().Be("myregistry/myapp");
|
||||
predicate.GetProperty("baseImage").GetProperty("digest").GetString()
|
||||
.Should().Be("sha256:base123");
|
||||
predicate.GetProperty("targetImage").GetProperty("name").GetString()
|
||||
.Should().Be("myregistry/myapp");
|
||||
predicate.GetProperty("targetImage").GetProperty("digest").GetString()
|
||||
.Should().Be("sha256:head456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Predicate_Contains_Analysis_Metadata()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
var predicate = ExtractPredicate(result.EnvelopeJson!);
|
||||
var analysis = predicate.GetProperty("analysis");
|
||||
|
||||
analysis.GetProperty("baseGraphDigest").GetString().Should().Be("sha256:graph-base");
|
||||
analysis.GetProperty("headGraphDigest").GetString().Should().Be("sha256:graph-head");
|
||||
analysis.GetProperty("scanner").GetProperty("name").GetString().Should().Be("StellaOps.Scanner");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Produces_Deterministic_Digest_For_Same_Input()
|
||||
{
|
||||
// Arrange
|
||||
var service = CreateService();
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result1 = await service.CreateAttestationAsync(request);
|
||||
var result2 = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result1.AttestationDigest.Should().Be(result2.AttestationDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_With_Signer_Service_Calls_SignAsync()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseSignerService = true;
|
||||
var signerMock = new Mock<IDriftSignerClient>();
|
||||
signerMock.Setup(x => x.SignAsync(It.IsAny<DriftSignerRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DriftSignerResult
|
||||
{
|
||||
Success = true,
|
||||
Signature = "base64-signature",
|
||||
KeyId = "test-key-id"
|
||||
});
|
||||
|
||||
var service = CreateService(signerMock.Object);
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.KeyId.Should().Be("test-key-id");
|
||||
signerMock.Verify(x => x.SignAsync(
|
||||
It.Is<DriftSignerRequest>(r => r.TenantId == "tenant-1"),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAttestationAsync_Returns_Failure_When_Signer_Fails()
|
||||
{
|
||||
// Arrange
|
||||
_options.UseSignerService = true;
|
||||
var signerMock = new Mock<IDriftSignerClient>();
|
||||
signerMock.Setup(x => x.SignAsync(It.IsAny<DriftSignerRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DriftSignerResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Key not found"
|
||||
});
|
||||
|
||||
var service = CreateService(signerMock.Object);
|
||||
var request = CreateValidRequest();
|
||||
|
||||
// Act
|
||||
var result = await service.CreateAttestationAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Key not found");
|
||||
}
|
||||
|
||||
private DriftAttestationService CreateService(IDriftSignerClient? signerClient = null)
|
||||
{
|
||||
return new DriftAttestationService(
|
||||
signerClient,
|
||||
_optionsMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<DriftAttestationService>.Instance);
|
||||
}
|
||||
|
||||
private DriftAttestationRequest CreateValidRequest()
|
||||
{
|
||||
var driftResult = new ReachabilityDriftResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BaseScanId = "scan-base-123",
|
||||
HeadScanId = "scan-head-456",
|
||||
Language = "csharp",
|
||||
DetectedAt = _timeProvider.GetUtcNow(),
|
||||
NewlyReachable = ImmutableArray.Create(CreateDriftedSink()),
|
||||
NewlyUnreachable = ImmutableArray<DriftedSink>.Empty,
|
||||
ResultDigest = "sha256:result-digest"
|
||||
};
|
||||
|
||||
return new DriftAttestationRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
DriftResult = driftResult,
|
||||
BaseImage = new ImageRef
|
||||
{
|
||||
Name = "myregistry/myapp",
|
||||
Digest = "sha256:base123",
|
||||
Tag = "v1.0.0"
|
||||
},
|
||||
TargetImage = new ImageRef
|
||||
{
|
||||
Name = "myregistry/myapp",
|
||||
Digest = "sha256:head456",
|
||||
Tag = "v1.1.0"
|
||||
},
|
||||
BaseGraphDigest = "sha256:graph-base",
|
||||
HeadGraphDigest = "sha256:graph-head"
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftedSink CreateDriftedSink()
|
||||
{
|
||||
return new DriftedSink
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SinkNodeId = "sink-node-1",
|
||||
Symbol = "SqlCommand.ExecuteNonQuery",
|
||||
SinkCategory = SinkCategory.SqlInjection,
|
||||
Direction = DriftDirection.BecameReachable,
|
||||
Cause = new DriftCause
|
||||
{
|
||||
Kind = DriftCauseKind.GuardRemoved,
|
||||
Description = "Security guard was removed from the call path"
|
||||
},
|
||||
Path = new CompressedPath
|
||||
{
|
||||
Entrypoint = new PathNode
|
||||
{
|
||||
NodeId = "entry-1",
|
||||
Symbol = "Program.Main",
|
||||
IsChanged = false
|
||||
},
|
||||
Sink = new PathNode
|
||||
{
|
||||
NodeId = "sink-1",
|
||||
Symbol = "SqlCommand.ExecuteNonQuery",
|
||||
IsChanged = false
|
||||
},
|
||||
KeyNodes = ImmutableArray<PathNode>.Empty,
|
||||
IntermediateCount = 3
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement ExtractPredicate(string envelopeJson)
|
||||
{
|
||||
var envelope = JsonDocument.Parse(envelopeJson);
|
||||
var payloadBase64 = envelope.RootElement.GetProperty("payload").GetString();
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64!);
|
||||
var statement = JsonDocument.Parse(payloadBytes);
|
||||
return statement.RootElement.GetProperty("predicate");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Time.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</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.Triage/StellaOps.Scanner.Triage.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL test fixture for Triage integration tests.
|
||||
/// Uses Testcontainers to spin up a real PostgreSQL instance.
|
||||
/// </summary>
|
||||
public sealed class TriagePostgresFixture : PostgresIntegrationFixture, ICollectionFixture<TriagePostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly() => typeof(TriageDbContext).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Scanner.Triage";
|
||||
}
|
||||
|
||||
[CollectionDefinition("triage-postgres")]
|
||||
public sealed class TriagePostgresCollection : ICollectionFixture<TriagePostgresFixture>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Query performance validation tests for the Triage schema.
|
||||
/// These tests verify that EXPLAIN ANALYZE results show efficient query plans.
|
||||
/// </summary>
|
||||
[Collection("triage-postgres")]
|
||||
public sealed class TriageQueryPerformanceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TriagePostgresFixture _fixture;
|
||||
private TriageDbContext? _context;
|
||||
|
||||
public TriageQueryPerformanceTests(TriagePostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<TriageDbContext>()
|
||||
.UseNpgsql(_fixture.ConnectionString);
|
||||
|
||||
_context = new TriageDbContext(optionsBuilder.Options);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_context != null)
|
||||
{
|
||||
await _context.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private TriageDbContext Context => _context ?? throw new InvalidOperationException("Context not initialized");
|
||||
|
||||
[Fact]
|
||||
public async Task Finding_Lookup_By_CVE_Uses_Index()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
await SeedTestData(100);
|
||||
|
||||
// Act - explain analyze a CVE lookup query
|
||||
var explainPlan = await Context.Database.SqlQueryRaw<string>(
|
||||
"EXPLAIN ANALYZE SELECT * FROM triage_finding WHERE cve_id = 'CVE-2021-23337'")
|
||||
.ToListAsync();
|
||||
|
||||
var planText = string.Join("\n", explainPlan);
|
||||
|
||||
// Assert - verify the query uses an index scan
|
||||
Assert.True(
|
||||
planText.Contains("Index", StringComparison.OrdinalIgnoreCase),
|
||||
$"Expected index scan in query plan, got: {planText}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finding_Lookup_By_Last_Seen_Uses_Index()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
await SeedTestData(100);
|
||||
|
||||
// Act
|
||||
var explainPlan = await Context.Database.SqlQueryRaw<string>(
|
||||
"EXPLAIN ANALYZE SELECT * FROM triage_finding WHERE last_seen_at > NOW() - INTERVAL '7 days' ORDER BY last_seen_at DESC LIMIT 10")
|
||||
.ToListAsync();
|
||||
|
||||
var planText = string.Join("\n", explainPlan);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
planText.Contains("Index", StringComparison.OrdinalIgnoreCase),
|
||||
$"Expected index usage in query plan for last_seen_at, got: {planText}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RiskResult_Lookup_By_Finding_Uses_Index()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
var findings = await SeedTestData(50);
|
||||
await SeedRiskResults(findings);
|
||||
|
||||
var targetFindingId = findings.First().Id;
|
||||
|
||||
// Act
|
||||
var explainPlan = await Context.Database.SqlQueryRaw<string>(
|
||||
$"EXPLAIN ANALYZE SELECT * FROM triage_risk_result WHERE finding_id = '{targetFindingId}'")
|
||||
.ToListAsync();
|
||||
|
||||
var planText = string.Join("\n", explainPlan);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
planText.Contains("Index", StringComparison.OrdinalIgnoreCase),
|
||||
$"Expected index scan for finding_id lookup, got: {planText}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decision_Active_Filter_Uses_Partial_Index()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
var findings = await SeedTestData(50);
|
||||
await SeedDecisions(findings);
|
||||
|
||||
var targetFindingId = findings.First().Id;
|
||||
|
||||
// Act - query for active decisions (revoked_at IS NULL)
|
||||
var explainPlan = await Context.Database.SqlQueryRaw<string>(
|
||||
$"EXPLAIN ANALYZE SELECT * FROM triage_decision WHERE finding_id = '{targetFindingId}' AND revoked_at IS NULL")
|
||||
.ToListAsync();
|
||||
|
||||
var planText = string.Join("\n", explainPlan);
|
||||
|
||||
// Assert - either index scan or we accept seq scan on small data
|
||||
Assert.True(
|
||||
planText.Contains("Scan", StringComparison.OrdinalIgnoreCase),
|
||||
$"Expected some scan type in query plan, got: {planText}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lane_Aggregation_Query_Is_Efficient()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
var findings = await SeedTestData(100);
|
||||
await SeedRiskResults(findings);
|
||||
|
||||
// Act - aggregate by lane
|
||||
var explainPlan = await Context.Database.SqlQueryRaw<string>(
|
||||
"EXPLAIN ANALYZE SELECT lane, COUNT(*) FROM triage_risk_result GROUP BY lane")
|
||||
.ToListAsync();
|
||||
|
||||
var planText = string.Join("\n", explainPlan);
|
||||
|
||||
// Assert - should complete efficiently
|
||||
Assert.True(
|
||||
planText.Contains("Aggregate", StringComparison.OrdinalIgnoreCase) ||
|
||||
planText.Contains("Group", StringComparison.OrdinalIgnoreCase) ||
|
||||
planText.Contains("Scan", StringComparison.OrdinalIgnoreCase),
|
||||
$"Expected aggregate or group in query plan, got: {planText}");
|
||||
}
|
||||
|
||||
private async Task<List<TriageFinding>> SeedTestData(int count)
|
||||
{
|
||||
var findings = new List<TriageFinding>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = Guid.NewGuid(),
|
||||
EnvironmentId = i % 5 == 0 ? Guid.NewGuid() : null,
|
||||
AssetLabel = $"prod/service-{i}:1.0.{i}",
|
||||
Purl = $"pkg:npm/package-{i}@1.0.{i}",
|
||||
CveId = i % 3 == 0 ? $"CVE-2021-{23337 + i}" : null,
|
||||
RuleId = i % 3 != 0 ? $"RULE-{i:D4}" : null,
|
||||
FirstSeenAt = DateTimeOffset.UtcNow.AddDays(-i),
|
||||
LastSeenAt = DateTimeOffset.UtcNow.AddHours(-i)
|
||||
};
|
||||
findings.Add(finding);
|
||||
}
|
||||
|
||||
Context.Findings.AddRange(findings);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
private async Task SeedRiskResults(List<TriageFinding> findings)
|
||||
{
|
||||
var lanes = Enum.GetValues<TriageLane>();
|
||||
var verdicts = Enum.GetValues<TriageVerdict>();
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var riskResult = new TriageRiskResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FindingId = finding.Id,
|
||||
PolicyId = "security-policy-v1",
|
||||
PolicyVersion = "1.0.0",
|
||||
InputsHash = Guid.NewGuid().ToString("N"),
|
||||
Score = Random.Shared.Next(0, 100),
|
||||
Verdict = verdicts[Random.Shared.Next(verdicts.Length)],
|
||||
Lane = lanes[Random.Shared.Next(lanes.Length)],
|
||||
Why = "Auto-generated test risk result",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Context.RiskResults.Add(riskResult);
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDecisions(List<TriageFinding> findings)
|
||||
{
|
||||
var kinds = Enum.GetValues<TriageDecisionKind>();
|
||||
|
||||
foreach (var finding in findings.Take(findings.Count / 2))
|
||||
{
|
||||
var decision = new TriageDecision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FindingId = finding.Id,
|
||||
Kind = kinds[Random.Shared.Next(kinds.Length)],
|
||||
ReasonCode = "TEST_REASON",
|
||||
ActorSubject = "user:test@example.com",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Context.Decisions.Add(decision);
|
||||
}
|
||||
|
||||
await Context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Triage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the Triage schema using Testcontainers.
|
||||
/// </summary>
|
||||
[Collection("triage-postgres")]
|
||||
public sealed class TriageSchemaIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TriagePostgresFixture _fixture;
|
||||
private TriageDbContext? _context;
|
||||
|
||||
public TriageSchemaIntegrationTests(TriagePostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<TriageDbContext>()
|
||||
.UseNpgsql(_fixture.ConnectionString);
|
||||
|
||||
_context = new TriageDbContext(optionsBuilder.Options);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_context != null)
|
||||
{
|
||||
await _context.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private TriageDbContext Context => _context ?? throw new InvalidOperationException("Context not initialized");
|
||||
|
||||
[Fact]
|
||||
public async Task Schema_Creates_Successfully()
|
||||
{
|
||||
// Arrange / Act
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
// Assert - verify tables exist by querying the metadata
|
||||
var findingsCount = await Context.Findings.CountAsync();
|
||||
var decisionsCount = await Context.Decisions.CountAsync();
|
||||
|
||||
Assert.Equal(0, findingsCount);
|
||||
Assert.Equal(0, decisionsCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Create_And_Query_TriageFinding()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api-gateway:1.2.3",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
CveId = "CVE-2021-23337",
|
||||
FirstSeenAt = DateTimeOffset.UtcNow,
|
||||
LastSeenAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
Context.Findings.Add(finding);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
var retrieved = await Context.Findings.FirstOrDefaultAsync(f => f.Id == finding.Id);
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(finding.AssetLabel, retrieved.AssetLabel);
|
||||
Assert.Equal(finding.Purl, retrieved.Purl);
|
||||
Assert.Equal(finding.CveId, retrieved.CveId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Create_TriageDecision_With_Finding()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api-gateway:1.2.3",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
CveId = "CVE-2021-23337"
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
var decision = new TriageDecision
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FindingId = finding.Id,
|
||||
Kind = TriageDecisionKind.MuteReach,
|
||||
ReasonCode = "NOT_REACHABLE",
|
||||
Note = "Code path is not reachable per RichGraph analysis",
|
||||
ActorSubject = "user:test@example.com",
|
||||
ActorDisplay = "Test User",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
Context.Decisions.Add(decision);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
var retrieved = await Context.Decisions
|
||||
.Include(d => d.Finding)
|
||||
.FirstOrDefaultAsync(d => d.Id == decision.Id);
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(TriageDecisionKind.MuteReach, retrieved.Kind);
|
||||
Assert.Equal("NOT_REACHABLE", retrieved.ReasonCode);
|
||||
Assert.NotNull(retrieved.Finding);
|
||||
Assert.Equal(finding.Purl, retrieved.Finding!.Purl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Can_Create_TriageRiskResult_With_Finding()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api-gateway:1.2.3",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
CveId = "CVE-2021-23337"
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
var riskResult = new TriageRiskResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FindingId = finding.Id,
|
||||
PolicyId = "security-policy-v1",
|
||||
PolicyVersion = "1.0.0",
|
||||
InputsHash = "abc123def456",
|
||||
Score = 75,
|
||||
Verdict = TriageVerdict.Block,
|
||||
Lane = TriageLane.Blocked,
|
||||
Why = "High-severity CVE with network exposure",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
Context.RiskResults.Add(riskResult);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
var retrieved = await Context.RiskResults
|
||||
.Include(r => r.Finding)
|
||||
.FirstOrDefaultAsync(r => r.Id == riskResult.Id);
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(75, retrieved.Score);
|
||||
Assert.Equal(TriageVerdict.Block, retrieved.Verdict);
|
||||
Assert.Equal(TriageLane.Blocked, retrieved.Lane);
|
||||
Assert.NotNull(retrieved.Finding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finding_Cascade_Deletes_Related_Entities()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api:1.0",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
CveId = "CVE-2024-0001"
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
var decision = new TriageDecision
|
||||
{
|
||||
FindingId = finding.Id,
|
||||
Kind = TriageDecisionKind.Ack,
|
||||
ReasonCode = "ACKNOWLEDGED",
|
||||
ActorSubject = "user:admin"
|
||||
};
|
||||
|
||||
var riskResult = new TriageRiskResult
|
||||
{
|
||||
FindingId = finding.Id,
|
||||
PolicyId = "policy-v1",
|
||||
PolicyVersion = "1.0",
|
||||
InputsHash = "hash123",
|
||||
Score = 50,
|
||||
Why = "Medium risk"
|
||||
};
|
||||
|
||||
Context.Decisions.Add(decision);
|
||||
Context.RiskResults.Add(riskResult);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Verify entities exist
|
||||
Assert.Single(await Context.Decisions.Where(d => d.FindingId == finding.Id).ToListAsync());
|
||||
Assert.Single(await Context.RiskResults.Where(r => r.FindingId == finding.Id).ToListAsync());
|
||||
|
||||
// Act - delete the finding
|
||||
Context.Findings.Remove(finding);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
// Assert - related entities should be cascade deleted
|
||||
Assert.Empty(await Context.Decisions.Where(d => d.FindingId == finding.Id).ToListAsync());
|
||||
Assert.Empty(await Context.RiskResults.Where(r => r.FindingId == finding.Id).ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unique_Constraint_Prevents_Duplicate_Findings()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
var assetId = Guid.NewGuid();
|
||||
var envId = Guid.NewGuid();
|
||||
const string purl = "pkg:npm/lodash@4.17.20";
|
||||
const string cveId = "CVE-2021-23337";
|
||||
|
||||
var finding1 = new TriageFinding
|
||||
{
|
||||
AssetId = assetId,
|
||||
EnvironmentId = envId,
|
||||
AssetLabel = "prod/api:1.0",
|
||||
Purl = purl,
|
||||
CveId = cveId
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding1);
|
||||
await Context.SaveChangesAsync();
|
||||
|
||||
var finding2 = new TriageFinding
|
||||
{
|
||||
AssetId = assetId,
|
||||
EnvironmentId = envId,
|
||||
AssetLabel = "prod/api:1.0",
|
||||
Purl = purl,
|
||||
CveId = cveId
|
||||
};
|
||||
|
||||
Context.Findings.Add(finding2);
|
||||
|
||||
// Act & Assert - should throw due to unique constraint
|
||||
await Assert.ThrowsAsync<DbUpdateException>(async () =>
|
||||
{
|
||||
await Context.SaveChangesAsync();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Indexes_Exist_For_Performance()
|
||||
{
|
||||
// Arrange
|
||||
await Context.Database.EnsureCreatedAsync();
|
||||
|
||||
// Act - query for indexes on triage_finding table
|
||||
var indexes = await Context.Database.SqlQueryRaw<string>(
|
||||
"SELECT indexname FROM pg_indexes WHERE tablename = 'triage_finding'")
|
||||
.ToListAsync();
|
||||
|
||||
// Assert - verify expected indexes exist
|
||||
Assert.Contains(indexes, i => i.Contains("last_seen"));
|
||||
Assert.Contains(indexes, i => i.Contains("purl"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user