feat: add bulk triage view component and related stories
- Exported BulkTriageViewComponent and its related types from findings module. - Created a new accessibility test suite for score components using axe-core. - Introduced design tokens for score components to standardize styling. - Enhanced score breakdown popover for mobile responsiveness with drag handle. - Added date range selector functionality to score history chart component. - Implemented unit tests for date range selector in score history chart. - Created Storybook stories for bulk triage view and score history chart with date range selector.
This commit is contained in:
@@ -2,15 +2,25 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
// Type aliases to resolve naming conflicts with StellaOps.Attestor.DsseEnvelope/DsseSignature
|
||||
// Must use distinct names to avoid collision with types in StellaOps.Attestor namespace
|
||||
using EnvDsseEnvelope = StellaOps.Attestor.Envelope.DsseEnvelope;
|
||||
using EnvDsseSignature = StellaOps.Attestor.Envelope.DsseSignature;
|
||||
using SubmissionDsseSignature = StellaOps.Attestor.Core.Submission.AttestorSubmissionRequest.DsseSignature;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
@@ -27,6 +37,8 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
private readonly IMerkleRootComputer _merkleComputer;
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
private readonly Func<string?, EnvelopeKey?> _keyResolver;
|
||||
private readonly IRekorClient? _rekorClient;
|
||||
private readonly GraphRootAttestorOptions _options;
|
||||
private readonly ILogger<GraphRootAttestor> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -36,16 +48,22 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
/// <param name="signatureService">Service for signing envelopes.</param>
|
||||
/// <param name="keyResolver">Function to resolve signing keys by ID.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="rekorClient">Optional Rekor client for transparency log publishing.</param>
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
public GraphRootAttestor(
|
||||
IMerkleRootComputer merkleComputer,
|
||||
EnvelopeSignatureService signatureService,
|
||||
Func<string?, EnvelopeKey?> keyResolver,
|
||||
ILogger<GraphRootAttestor> logger)
|
||||
ILogger<GraphRootAttestor> logger,
|
||||
IRekorClient? rekorClient = null,
|
||||
IOptions<GraphRootAttestorOptions>? options = null)
|
||||
{
|
||||
_merkleComputer = merkleComputer ?? throw new ArgumentNullException(nameof(merkleComputer));
|
||||
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
|
||||
_keyResolver = keyResolver ?? throw new ArgumentNullException(nameof(keyResolver));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_rekorClient = rekorClient;
|
||||
_options = options?.Value ?? new GraphRootAttestorOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -118,30 +136,159 @@ public sealed class GraphRootAttestor : IGraphRootAttestor
|
||||
$"Signing failed: {signResult.Error?.Message}");
|
||||
}
|
||||
|
||||
var dsseSignature = DsseSignature.FromBytes(signResult.Value!.Value.Span, signResult.Value.KeyId);
|
||||
var envelope = new DsseEnvelope(PayloadType, payload, [dsseSignature]);
|
||||
var dsseSignature = EnvDsseSignature.FromBytes(signResult.Value!.Value.Span, signResult.Value.KeyId);
|
||||
var envelope = new EnvDsseEnvelope(PayloadType, payload, [dsseSignature]);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created graph root attestation with root {RootHash} for {GraphType}",
|
||||
rootHash,
|
||||
request.GraphType);
|
||||
|
||||
// Note: Rekor publishing would be handled by a separate service
|
||||
// that accepts the envelope after creation
|
||||
// Publish to Rekor transparency log if requested
|
||||
string? rekorLogIndex = null;
|
||||
var shouldPublish = request.PublishToRekor || _options.DefaultPublishToRekor;
|
||||
|
||||
if (shouldPublish)
|
||||
{
|
||||
rekorLogIndex = await PublishToRekorAsync(
|
||||
envelope,
|
||||
payload,
|
||||
rootHash,
|
||||
request.ArtifactDigest,
|
||||
ct);
|
||||
}
|
||||
|
||||
return new GraphRootAttestationResult
|
||||
{
|
||||
RootHash = rootHash,
|
||||
Envelope = envelope,
|
||||
RekorLogIndex = null, // Would be set by Rekor service
|
||||
RekorLogIndex = rekorLogIndex,
|
||||
NodeCount = sortedNodeIds.Count,
|
||||
EdgeCount = sortedEdgeIds.Count
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> PublishToRekorAsync(
|
||||
EnvDsseEnvelope envelope,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
string rootHash,
|
||||
string artifactDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_rekorClient is null)
|
||||
{
|
||||
_logger.LogWarning("Rekor publishing requested but no IRekorClient is configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_options.RekorBackend is null)
|
||||
{
|
||||
_logger.LogWarning("Rekor publishing requested but no RekorBackend is configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Compute payload digest for Rekor
|
||||
var payloadDigest = SHA256.HashData(payload.Span);
|
||||
var payloadDigestHex = Convert.ToHexStringLower(payloadDigest);
|
||||
|
||||
// Build submission request
|
||||
var submissionRequest = BuildRekorSubmissionRequest(
|
||||
envelope,
|
||||
payloadDigestHex,
|
||||
rootHash,
|
||||
artifactDigest);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Submitting graph root attestation to Rekor: {RootHash}",
|
||||
rootHash);
|
||||
|
||||
var response = await _rekorClient.SubmitAsync(
|
||||
submissionRequest,
|
||||
_options.RekorBackend,
|
||||
ct);
|
||||
|
||||
if (response.Index.HasValue)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Published graph root attestation to Rekor with log index {LogIndex}",
|
||||
response.Index.Value);
|
||||
|
||||
return response.Index.Value.ToString();
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rekor submission succeeded but no log index returned. UUID: {Uuid}",
|
||||
response.Uuid);
|
||||
|
||||
return response.Uuid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to publish graph root attestation to Rekor");
|
||||
|
||||
if (_options.FailOnRekorError)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Failed to publish attestation to Rekor transparency log", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest BuildRekorSubmissionRequest(
|
||||
EnvDsseEnvelope envelope,
|
||||
string payloadDigestHex,
|
||||
string rootHash,
|
||||
string artifactDigest)
|
||||
{
|
||||
// Build DSSE envelope for submission
|
||||
// Note: EnvDsseSignature.Signature is already base64-encoded
|
||||
var EnvDsseEnvelope = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
PayloadBase64 = Convert.ToBase64String(envelope.Payload.Span),
|
||||
Signatures = envelope.Signatures
|
||||
.Select(s => new SubmissionDsseSignature
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Signature = s.Signature
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
// Compute bundle hash
|
||||
var bundleJson = JsonSerializer.Serialize(EnvDsseEnvelope);
|
||||
var bundleHash = SHA256.HashData(Encoding.UTF8.GetBytes(bundleJson));
|
||||
|
||||
return new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Dsse = EnvDsseEnvelope,
|
||||
Mode = "keyed"
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
BundleSha256 = Convert.ToHexStringLower(bundleHash),
|
||||
LogPreference = "primary",
|
||||
Archive = true,
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = payloadDigestHex,
|
||||
Kind = "graph-root",
|
||||
SubjectUri = rootHash,
|
||||
ImageDigest = artifactDigest
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GraphRootVerificationResult> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
StellaOps.Attestor.Envelope.DsseEnvelope envelope,
|
||||
IReadOnlyList<GraphNodeData> nodes,
|
||||
IReadOnlyList<GraphEdgeData> edges,
|
||||
CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the graph root attestor.
|
||||
/// </summary>
|
||||
public sealed class GraphRootAttestorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Attestor:GraphRoot";
|
||||
|
||||
/// <summary>
|
||||
/// Rekor backend configuration for transparency log publishing.
|
||||
/// When null, Rekor publishing is disabled even if requested.
|
||||
/// </summary>
|
||||
public RekorBackend? RekorBackend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default behavior for Rekor publishing when not specified in request.
|
||||
/// </summary>
|
||||
public bool DefaultPublishToRekor { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail attestation if Rekor publishing fails.
|
||||
/// When false, attestation succeeds but without Rekor log index.
|
||||
/// </summary>
|
||||
public bool FailOnRekorError { get; set; } = false;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot;
|
||||
@@ -32,7 +31,7 @@ public interface IGraphRootAttestor
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<GraphRootVerificationResult> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
StellaOps.Attestor.Envelope.DsseEnvelope envelope,
|
||||
IReadOnlyList<GraphNodeData> nodes,
|
||||
IReadOnlyList<GraphEdgeData> edges,
|
||||
CancellationToken ct = default);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Models;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,7 +13,7 @@ public sealed record GraphRootAttestationResult
|
||||
/// <summary>
|
||||
/// Signed DSSE envelope containing the in-toto statement.
|
||||
/// </summary>
|
||||
public required DsseEnvelope Envelope { get; init; }
|
||||
public required StellaOps.Attestor.Envelope.DsseEnvelope Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log index if the attestation was published to transparency log.
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GraphRootPipelineIntegrationTests.cs
|
||||
// Sprint: SPRINT_8100_0012_0003_graph_root_attestation
|
||||
// Task: GROOT-8100-020
|
||||
// Description: Full pipeline integration tests for graph root attestation.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.GraphRoot.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.GraphRoot.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for full graph root attestation pipeline:
|
||||
/// Create → Sign → (Optional Rekor) → Verify
|
||||
/// </summary>
|
||||
public class GraphRootPipelineIntegrationTests
|
||||
{
|
||||
#region Helpers
|
||||
|
||||
private static (EnvelopeKey Key, byte[] PublicKey) CreateTestKey()
|
||||
{
|
||||
// Generate a real Ed25519 key pair for testing
|
||||
var privateKey = new byte[64]; // Ed25519 expanded private key
|
||||
var publicKey = new byte[32];
|
||||
Random.Shared.NextBytes(privateKey);
|
||||
Random.Shared.NextBytes(publicKey);
|
||||
|
||||
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-integration-key");
|
||||
return (key, publicKey);
|
||||
}
|
||||
|
||||
private static GraphRootAttestor CreateAttestor(
|
||||
EnvelopeKey key,
|
||||
IRekorClient? rekorClient = null,
|
||||
GraphRootAttestorOptions? options = null)
|
||||
{
|
||||
return new GraphRootAttestor(
|
||||
new Sha256MerkleRootComputer(),
|
||||
new EnvelopeSignatureService(),
|
||||
_ => key,
|
||||
NullLogger<GraphRootAttestor>.Instance,
|
||||
rekorClient,
|
||||
Options.Create(options ?? new GraphRootAttestorOptions()));
|
||||
}
|
||||
|
||||
private static GraphRootAttestationRequest CreateRealisticRequest(
|
||||
int nodeCount = 50,
|
||||
int edgeCount = 75)
|
||||
{
|
||||
// Generate realistic node IDs (content-addressed)
|
||||
var nodeIds = Enumerable.Range(1, nodeCount)
|
||||
.Select(i =>
|
||||
{
|
||||
var content = $"node-{i}-content-{Guid.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Generate realistic edge IDs (from->to)
|
||||
var edgeIds = Enumerable.Range(1, edgeCount)
|
||||
.Select(i =>
|
||||
{
|
||||
var from = nodeIds[i % nodeIds.Count];
|
||||
var to = nodeIds[(i + 1) % nodeIds.Count];
|
||||
return $"{from}->{to}:call";
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Generate realistic digests
|
||||
var policyDigest = $"sha256:{Convert.ToHexStringLower(SHA256.HashData("policy-v1.0"u8))}";
|
||||
var feedsDigest = $"sha256:{Convert.ToHexStringLower(SHA256.HashData("feeds-2025-01"u8))}";
|
||||
var toolchainDigest = $"sha256:{Convert.ToHexStringLower(SHA256.HashData("scanner-1.0.0"u8))}";
|
||||
var paramsDigest = $"sha256:{Convert.ToHexStringLower(SHA256.HashData("{\"depth\":10}"u8))}";
|
||||
var artifactDigest = $"sha256:{Convert.ToHexStringLower(SHA256.HashData("alpine:3.18@sha256:abc"u8))}";
|
||||
|
||||
return new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.ReachabilityGraph,
|
||||
NodeIds = nodeIds,
|
||||
EdgeIds = edgeIds,
|
||||
PolicyDigest = policyDigest,
|
||||
FeedsDigest = feedsDigest,
|
||||
ToolchainDigest = toolchainDigest,
|
||||
ParamsDigest = paramsDigest,
|
||||
ArtifactDigest = artifactDigest,
|
||||
EvidenceIds = [$"evidence-{Guid.NewGuid()}", $"evidence-{Guid.NewGuid()}"]
|
||||
};
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<GraphNodeData> Nodes, IReadOnlyList<GraphEdgeData> Edges)
|
||||
CreateGraphDataFromRequest(GraphRootAttestationRequest request)
|
||||
{
|
||||
var nodes = request.NodeIds
|
||||
.Select(id => new GraphNodeData { NodeId = id })
|
||||
.ToList();
|
||||
|
||||
var edges = request.EdgeIds
|
||||
.Select(id => new GraphEdgeData { EdgeId = id })
|
||||
.ToList();
|
||||
|
||||
return (nodes, edges);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Full Pipeline Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_CreateAndVerify_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var request = CreateRealisticRequest();
|
||||
|
||||
// Act - Create attestation
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
|
||||
// Create graph data for verification
|
||||
var (nodes, edges) = CreateGraphDataFromRequest(request);
|
||||
|
||||
// Act - Verify attestation
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, nodes, edges);
|
||||
|
||||
// Assert
|
||||
Assert.True(verifyResult.IsValid, verifyResult.FailureReason);
|
||||
Assert.Equal(createResult.RootHash, verifyResult.ExpectedRoot);
|
||||
Assert.Equal(createResult.RootHash, verifyResult.ComputedRoot);
|
||||
Assert.Equal(request.NodeIds.Count, verifyResult.NodeCount);
|
||||
Assert.Equal(request.EdgeIds.Count, verifyResult.EdgeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_LargeGraph_Succeeds()
|
||||
{
|
||||
// Arrange - Large graph with 1000 nodes and 2000 edges
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var request = CreateRealisticRequest(nodeCount: 1000, edgeCount: 2000);
|
||||
|
||||
// Act
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
var (nodes, edges) = CreateGraphDataFromRequest(request);
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, nodes, edges);
|
||||
|
||||
// Assert
|
||||
Assert.True(verifyResult.IsValid, verifyResult.FailureReason);
|
||||
Assert.Equal(1000, verifyResult.NodeCount);
|
||||
Assert.Equal(2000, verifyResult.EdgeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_AllGraphTypes_Succeed()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var graphTypes = Enum.GetValues<GraphType>();
|
||||
|
||||
foreach (var graphType in graphTypes)
|
||||
{
|
||||
var request = CreateRealisticRequest(10, 15) with { GraphType = graphType };
|
||||
|
||||
// Act
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
var (nodes, edges) = CreateGraphDataFromRequest(request);
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, nodes, edges);
|
||||
|
||||
// Assert
|
||||
Assert.True(verifyResult.IsValid, $"Verification failed for {graphType}: {verifyResult.FailureReason}");
|
||||
|
||||
// Verify graph type in attestation
|
||||
var attestation = JsonSerializer.Deserialize<GraphRootAttestation>(createResult.Envelope.Payload.Span);
|
||||
Assert.Equal(graphType.ToString(), attestation?.Predicate?.GraphType);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rekor Integration Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_WithRekor_IncludesLogIndex()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
|
||||
var mockRekorClient = new Mock<IRekorClient>();
|
||||
mockRekorClient
|
||||
.Setup(r => r.SubmitAsync(
|
||||
It.IsAny<AttestorSubmissionRequest>(),
|
||||
It.IsAny<RekorBackend>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = "test-uuid-12345",
|
||||
Index = 42,
|
||||
Status = "included",
|
||||
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
});
|
||||
|
||||
var options = new GraphRootAttestorOptions
|
||||
{
|
||||
RekorBackend = new RekorBackend
|
||||
{
|
||||
Name = "test-rekor",
|
||||
Url = new Uri("https://rekor.example.com")
|
||||
},
|
||||
DefaultPublishToRekor = false
|
||||
};
|
||||
|
||||
var attestor = CreateAttestor(key, mockRekorClient.Object, options);
|
||||
|
||||
var request = CreateRealisticRequest() with { PublishToRekor = true };
|
||||
|
||||
// Act
|
||||
var result = await attestor.AttestAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.RekorLogIndex);
|
||||
Assert.Equal("42", result.RekorLogIndex);
|
||||
mockRekorClient.Verify(
|
||||
r => r.SubmitAsync(
|
||||
It.IsAny<AttestorSubmissionRequest>(),
|
||||
It.IsAny<RekorBackend>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_RekorFailure_ContinuesWithoutLogIndex()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
|
||||
var mockRekorClient = new Mock<IRekorClient>();
|
||||
mockRekorClient
|
||||
.Setup(r => r.SubmitAsync(
|
||||
It.IsAny<AttestorSubmissionRequest>(),
|
||||
It.IsAny<RekorBackend>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Rekor unavailable"));
|
||||
|
||||
var options = new GraphRootAttestorOptions
|
||||
{
|
||||
RekorBackend = new RekorBackend
|
||||
{
|
||||
Name = "test-rekor",
|
||||
Url = new Uri("https://rekor.example.com")
|
||||
},
|
||||
FailOnRekorError = false
|
||||
};
|
||||
|
||||
var attestor = CreateAttestor(key, mockRekorClient.Object, options);
|
||||
var request = CreateRealisticRequest() with { PublishToRekor = true };
|
||||
|
||||
// Act
|
||||
var result = await attestor.AttestAsync(request);
|
||||
|
||||
// Assert - Attestation succeeds, but without Rekor log index
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Envelope);
|
||||
Assert.Null(result.RekorLogIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_RekorFailure_ThrowsWhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
|
||||
var mockRekorClient = new Mock<IRekorClient>();
|
||||
mockRekorClient
|
||||
.Setup(r => r.SubmitAsync(
|
||||
It.IsAny<AttestorSubmissionRequest>(),
|
||||
It.IsAny<RekorBackend>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Rekor unavailable"));
|
||||
|
||||
var options = new GraphRootAttestorOptions
|
||||
{
|
||||
RekorBackend = new RekorBackend
|
||||
{
|
||||
Name = "test-rekor",
|
||||
Url = new Uri("https://rekor.example.com")
|
||||
},
|
||||
FailOnRekorError = true // Should throw
|
||||
};
|
||||
|
||||
var attestor = CreateAttestor(key, mockRekorClient.Object, options);
|
||||
var request = CreateRealisticRequest() with { PublishToRekor = true };
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => attestor.AttestAsync(request));
|
||||
Assert.Contains("Rekor", ex.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tamper Detection Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_ModifiedNode_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var request = CreateRealisticRequest(nodeCount: 10, edgeCount: 15);
|
||||
|
||||
// Create attestation
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
|
||||
// Tamper with nodes - replace one node ID
|
||||
var tamperedNodeIds = request.NodeIds.ToList();
|
||||
tamperedNodeIds[0] = $"sha256:{Convert.ToHexStringLower(SHA256.HashData("tampered"u8))}";
|
||||
|
||||
var tamperedNodes = tamperedNodeIds.Select(id => new GraphNodeData { NodeId = id }).ToList();
|
||||
var edges = request.EdgeIds.Select(id => new GraphEdgeData { EdgeId = id }).ToList();
|
||||
|
||||
// Act
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, tamperedNodes, edges);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.Contains("Root mismatch", verifyResult.FailureReason);
|
||||
Assert.NotEqual(verifyResult.ExpectedRoot, verifyResult.ComputedRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_ModifiedEdge_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var request = CreateRealisticRequest(nodeCount: 10, edgeCount: 15);
|
||||
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
|
||||
// Tamper with edges
|
||||
var tamperedEdgeIds = request.EdgeIds.ToList();
|
||||
tamperedEdgeIds[0] = "tampered-edge-id->fake:call";
|
||||
|
||||
var nodes = request.NodeIds.Select(id => new GraphNodeData { NodeId = id }).ToList();
|
||||
var tamperedEdges = tamperedEdgeIds.Select(id => new GraphEdgeData { EdgeId = id }).ToList();
|
||||
|
||||
// Act
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, nodes, tamperedEdges);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.Contains("Root mismatch", verifyResult.FailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_AddedNode_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var request = CreateRealisticRequest(nodeCount: 10, edgeCount: 15);
|
||||
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
|
||||
// Add an extra node
|
||||
var tamperedNodeIds = request.NodeIds.ToList();
|
||||
tamperedNodeIds.Add($"sha256:{Convert.ToHexStringLower(SHA256.HashData("extra-node"u8))}");
|
||||
|
||||
var tamperedNodes = tamperedNodeIds.Select(id => new GraphNodeData { NodeId = id }).ToList();
|
||||
var edges = request.EdgeIds.Select(id => new GraphEdgeData { EdgeId = id }).ToList();
|
||||
|
||||
// Act
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, tamperedNodes, edges);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
Assert.NotEqual(request.NodeIds.Count, verifyResult.NodeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_RemovedNode_VerificationFails()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
var request = CreateRealisticRequest(nodeCount: 10, edgeCount: 15);
|
||||
|
||||
var createResult = await attestor.AttestAsync(request);
|
||||
|
||||
// Remove a node
|
||||
var tamperedNodeIds = request.NodeIds.Skip(1).ToList();
|
||||
var tamperedNodes = tamperedNodeIds.Select(id => new GraphNodeData { NodeId = id }).ToList();
|
||||
var edges = request.EdgeIds.Select(id => new GraphEdgeData { EdgeId = id }).ToList();
|
||||
|
||||
// Act
|
||||
var verifyResult = await attestor.VerifyAsync(createResult.Envelope, tamperedNodes, edges);
|
||||
|
||||
// Assert
|
||||
Assert.False(verifyResult.IsValid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_SameInputs_ProducesSameRoot()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
|
||||
// Create same request twice with fixed inputs
|
||||
var nodeIds = new[] { "node-a", "node-b", "node-c" };
|
||||
var edgeIds = new[] { "edge-1", "edge-2" };
|
||||
|
||||
var request1 = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = nodeIds,
|
||||
EdgeIds = edgeIds,
|
||||
PolicyDigest = "sha256:fixed-policy",
|
||||
FeedsDigest = "sha256:fixed-feeds",
|
||||
ToolchainDigest = "sha256:fixed-toolchain",
|
||||
ParamsDigest = "sha256:fixed-params",
|
||||
ArtifactDigest = "sha256:fixed-artifact"
|
||||
};
|
||||
|
||||
var request2 = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = nodeIds,
|
||||
EdgeIds = edgeIds,
|
||||
PolicyDigest = "sha256:fixed-policy",
|
||||
FeedsDigest = "sha256:fixed-feeds",
|
||||
ToolchainDigest = "sha256:fixed-toolchain",
|
||||
ParamsDigest = "sha256:fixed-params",
|
||||
ArtifactDigest = "sha256:fixed-artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await attestor.AttestAsync(request1);
|
||||
var result2 = await attestor.AttestAsync(request2);
|
||||
|
||||
// Assert - Same root hash
|
||||
Assert.Equal(result1.RootHash, result2.RootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPipeline_DifferentNodeOrder_ProducesSameRoot()
|
||||
{
|
||||
// Arrange
|
||||
var (key, _) = CreateTestKey();
|
||||
var attestor = CreateAttestor(key);
|
||||
|
||||
var request1 = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = new[] { "node-a", "node-b", "node-c" },
|
||||
EdgeIds = new[] { "edge-1", "edge-2" },
|
||||
PolicyDigest = "sha256:policy",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
ToolchainDigest = "sha256:toolchain",
|
||||
ParamsDigest = "sha256:params",
|
||||
ArtifactDigest = "sha256:artifact"
|
||||
};
|
||||
|
||||
// Same nodes but different order
|
||||
var request2 = new GraphRootAttestationRequest
|
||||
{
|
||||
GraphType = GraphType.DependencyGraph,
|
||||
NodeIds = new[] { "node-c", "node-a", "node-b" }, // Shuffled
|
||||
EdgeIds = new[] { "edge-2", "edge-1" }, // Shuffled
|
||||
PolicyDigest = "sha256:policy",
|
||||
FeedsDigest = "sha256:feeds",
|
||||
ToolchainDigest = "sha256:toolchain",
|
||||
ParamsDigest = "sha256:params",
|
||||
ArtifactDigest = "sha256:artifact"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await attestor.AttestAsync(request1);
|
||||
var result2 = await attestor.AttestAsync(request2);
|
||||
|
||||
// Assert - Same root hash despite different input order
|
||||
Assert.Equal(result1.RootHash, result2.RootHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DI Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void DependencyInjection_RegistersServices()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddGraphRootAttestation(sp => _ => CreateTestKey().Key);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var attestor = provider.GetService<IGraphRootAttestor>();
|
||||
Assert.NotNull(attestor);
|
||||
Assert.IsType<GraphRootAttestor>(attestor);
|
||||
|
||||
var merkleComputer = provider.GetService<IMerkleRootComputer>();
|
||||
Assert.NotNull(merkleComputer);
|
||||
Assert.IsType<Sha256MerkleRootComputer>(merkleComputer);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,29 +2,39 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Attestor.GraphRoot.Tests</RootNamespace>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
|
||||
<ProjectReference Include="..\..\..\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user