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:
StellaOps Bot
2025-12-26 01:01:35 +02:00
parent ed3079543c
commit 17613acf57
45 changed files with 9418 additions and 64 deletions

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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.

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>