// ----------------------------------------------------------------------------- // AttestingRichGraphWriterTests.cs // Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse // Description: Tests for AttestingRichGraphWriter integration. // ----------------------------------------------------------------------------- using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Cryptography; using StellaOps.Scanner.Reachability.Attestation; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.Reachability.Tests; public class AttestingRichGraphWriterTests : IAsyncLifetime { private DirectoryInfo _tempDir = null!; public Task InitializeAsync() { _tempDir = Directory.CreateTempSubdirectory("attesting-writer-test-"); return Task.CompletedTask; } public Task DisposeAsync() { try { if (_tempDir.Exists) { _tempDir.Delete(recursive: true); } } catch { // Ignore cleanup errors } return Task.CompletedTask; } [Trait("Category", TestCategories.Unit)] [Fact] public async Task WriteWithAttestationAsync_WhenEnabled_ProducesAttestationFile() { // Arrange var cryptoHash = new TestCryptoHash(); var graphWriter = new RichGraphWriter(cryptoHash); var witnessOptions = Options.Create(new ReachabilityWitnessOptions { Enabled = true, StoreInCas = false, PublishToRekor = false }); var witnessPublisher = new ReachabilityWitnessPublisher( witnessOptions, cryptoHash, NullLogger.Instance); var writer = new AttestingRichGraphWriter( graphWriter, witnessPublisher, witnessOptions, NullLogger.Instance); var graph = CreateTestGraph(); // Act var result = await writer.WriteWithAttestationAsync( graph, _tempDir.FullName, "test-analysis", "sha256:abc123"); // Assert Assert.NotNull(result); Assert.True(File.Exists(result.GraphPath)); Assert.True(File.Exists(result.MetaPath)); Assert.NotNull(result.AttestationPath); Assert.True(File.Exists(result.AttestationPath)); Assert.NotNull(result.WitnessResult); Assert.NotEmpty(result.WitnessResult.StatementHash); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task WriteWithAttestationAsync_WhenDisabled_NoAttestationFile() { // Arrange var cryptoHash = new TestCryptoHash(); var graphWriter = new RichGraphWriter(cryptoHash); var witnessOptions = Options.Create(new ReachabilityWitnessOptions { Enabled = false }); var witnessPublisher = new ReachabilityWitnessPublisher( witnessOptions, cryptoHash, NullLogger.Instance); var writer = new AttestingRichGraphWriter( graphWriter, witnessPublisher, witnessOptions, NullLogger.Instance); var graph = CreateTestGraph(); // Act var result = await writer.WriteWithAttestationAsync( graph, _tempDir.FullName, "test-analysis", "sha256:abc123"); // Assert Assert.NotNull(result); Assert.True(File.Exists(result.GraphPath)); Assert.True(File.Exists(result.MetaPath)); Assert.Null(result.AttestationPath); Assert.Null(result.WitnessResult); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task WriteWithAttestationAsync_AttestationContainsValidDsse() { // Arrange var cryptoHash = new TestCryptoHash(); var graphWriter = new RichGraphWriter(cryptoHash); var witnessOptions = Options.Create(new ReachabilityWitnessOptions { Enabled = true, StoreInCas = false, PublishToRekor = false }); var witnessPublisher = new ReachabilityWitnessPublisher( witnessOptions, cryptoHash, NullLogger.Instance); var writer = new AttestingRichGraphWriter( graphWriter, witnessPublisher, witnessOptions, NullLogger.Instance); var graph = CreateTestGraph(); // Act var result = await writer.WriteWithAttestationAsync( graph, _tempDir.FullName, "test-analysis", "sha256:abc123"); // Assert Assert.NotNull(result.AttestationPath); var dsseJson = await File.ReadAllTextAsync(result.AttestationPath); Assert.Contains("payloadType", dsseJson); // Note: + may be encoded as \u002B in JSON Assert.True(dsseJson.Contains("application/vnd.in-toto+json") || dsseJson.Contains("application/vnd.in-toto\\u002Bjson")); Assert.Contains("payload", dsseJson); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task WriteWithAttestationAsync_GraphHashIsDeterministic() { // Arrange var cryptoHash = new TestCryptoHash(); var graphWriter = new RichGraphWriter(cryptoHash); var witnessOptions = Options.Create(new ReachabilityWitnessOptions { Enabled = true, StoreInCas = false, PublishToRekor = false }); var witnessPublisher = new ReachabilityWitnessPublisher( witnessOptions, cryptoHash, NullLogger.Instance); var writer = new AttestingRichGraphWriter( graphWriter, witnessPublisher, witnessOptions, NullLogger.Instance); var graph = CreateTestGraph(); // Act - write twice with same input var result1 = await writer.WriteWithAttestationAsync( graph, _tempDir.FullName, "analysis-1", "sha256:abc123"); var result2 = await writer.WriteWithAttestationAsync( graph, _tempDir.FullName, "analysis-2", "sha256:abc123"); // Assert - same graph should produce same hash Assert.Equal(result1.GraphHash, result2.GraphHash); } private static RichGraph CreateTestGraph() { return new RichGraph( Nodes: new[] { new RichGraphNode( Id: "entry-1", SymbolId: "Handler.handle", CodeId: null, Purl: "pkg:maven/com.example/handler@1.0.0", Lang: "java", Kind: "http_handler", Display: "GET /api/users", BuildId: null, Evidence: null, Attributes: null, SymbolDigest: "sha256:entry1digest"), new RichGraphNode( Id: "sink-1", SymbolId: "DB.executeQuery", CodeId: null, Purl: "pkg:maven/org.database/driver@2.0.0", Lang: "java", Kind: "sql_sink", Display: "executeQuery(String)", BuildId: null, Evidence: null, Attributes: new Dictionary { ["is_sink"] = "true" }, SymbolDigest: "sha256:sink1digest") }, Edges: new[] { new RichGraphEdge( From: "entry-1", To: "sink-1", Kind: "call", Purl: null, SymbolDigest: null, Evidence: null, Confidence: 1.0, Candidates: null) }, Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) }, Analyzer: new RichGraphAnalyzer("stellaops.scanner.reachability", "1.0.0", null), Schema: "richgraph-v1" ); } /// /// Test crypto hash implementation. /// private sealed class TestCryptoHash : ICryptoHash { public byte[] ComputeHash(ReadOnlySpan data, string? algorithmId = null) => System.Security.Cryptography.SHA256.HashData(data); public string ComputeHashHex(ReadOnlySpan data, string? algorithmId = null) => Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant(); public string ComputeHashBase64(ReadOnlySpan data, string? algorithmId = null) => Convert.ToBase64String(ComputeHash(data, algorithmId)); public async ValueTask ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) { using var buffer = new MemoryStream(); await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); return System.Security.Cryptography.SHA256.HashData(buffer.ToArray()); } public async ValueTask ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default) { var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hash).ToLowerInvariant(); } public byte[] ComputeHashForPurpose(ReadOnlySpan data, string purpose) => ComputeHash(data); public string ComputeHashHexForPurpose(ReadOnlySpan data, string purpose) => ComputeHashHex(data); public string ComputeHashBase64ForPurpose(ReadOnlySpan data, string purpose) => ComputeHashBase64(data); public async ValueTask ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => await ComputeHashAsync(stream, null, cancellationToken).ConfigureAwait(false); public async ValueTask ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default) => await ComputeHashHexAsync(stream, null, cancellationToken).ConfigureAwait(false); public string GetAlgorithmForPurpose(string purpose) => "blake3"; public string GetHashPrefix(string purpose) => "blake3:"; public string ComputePrefixedHashForPurpose(ReadOnlySpan data, string purpose) => $"blake3:{ComputeHashHex(data)}"; } }