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

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