feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration
- Add RateLimitConfig for configuration management with YAML binding support. - Introduce RateLimitDecision to encapsulate the result of rate limit checks. - Implement RateLimitMetrics for OpenTelemetry metrics tracking. - Create RateLimitMiddleware for enforcing rate limits on incoming requests. - Develop RateLimitService to orchestrate instance and environment rate limit checks. - Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps Contributors
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Attestor.ProofChain;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Attestor.ProofChain.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Load tests for proof chain API endpoints and verification pipeline.
|
||||
/// Sprint: SPRINT_0501_0005_0001_proof_chain_api_surface
|
||||
/// Task: PROOF-API-0012
|
||||
/// </summary>
|
||||
public class ApiLoadTests
|
||||
{
|
||||
private readonly ILogger<VerificationPipeline> _logger = NullLogger<VerificationPipeline>.Instance;
|
||||
|
||||
#region Proof Spine Creation Load Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProofSpine_ConcurrentRequests_MaintainsThroughput()
|
||||
{
|
||||
// Arrange: Create synthetic SBOM entries for load testing
|
||||
const int concurrencyLevel = 50;
|
||||
const int operationsPerClient = 20;
|
||||
var totalOperations = concurrencyLevel * operationsPerClient;
|
||||
|
||||
var proofSpineBuilder = CreateTestProofSpineBuilder();
|
||||
var latencies = new ConcurrentBag<long>();
|
||||
var errors = new ConcurrentBag<Exception>();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Act: Run concurrent proof spine creations
|
||||
var tasks = Enumerable.Range(0, concurrencyLevel)
|
||||
.Select(clientId => Task.Run(async () =>
|
||||
{
|
||||
for (var i = 0; i < operationsPerClient; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var entryId = GenerateSyntheticEntryId(clientId, i);
|
||||
var spine = await proofSpineBuilder.BuildAsync(
|
||||
entryId,
|
||||
GenerateSyntheticEvidenceIds(3),
|
||||
$"sha256:{GenerateHash("reasoning")}",
|
||||
$"sha256:{GenerateHash("vex")}",
|
||||
"v2.3.1",
|
||||
CancellationToken.None);
|
||||
sw.Stop();
|
||||
latencies.Add(sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(ex);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
stopwatch.Stop();
|
||||
|
||||
// Assert: Verify load test metrics
|
||||
var successCount = latencies.Count;
|
||||
var errorCount = errors.Count;
|
||||
var throughput = successCount / stopwatch.Elapsed.TotalSeconds;
|
||||
var avgLatency = latencies.Any() ? latencies.Average() : 0;
|
||||
var p95Latency = CalculatePercentile(latencies, 95);
|
||||
var p99Latency = CalculatePercentile(latencies, 99);
|
||||
|
||||
// Performance assertions
|
||||
successCount.Should().Be(totalOperations, "all operations should complete successfully");
|
||||
errorCount.Should().Be(0, "no errors should occur during load test");
|
||||
throughput.Should().BeGreaterThan(100, "throughput should exceed 100 ops/sec");
|
||||
avgLatency.Should().BeLessThan(50, "average latency should be under 50ms");
|
||||
p99Latency.Should().BeLessThan(200, "p99 latency should be under 200ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationPipeline_ConcurrentVerifications_MaintainsAccuracy()
|
||||
{
|
||||
// Arrange
|
||||
const int concurrencyLevel = 30;
|
||||
const int verificationsPerClient = 10;
|
||||
var totalVerifications = concurrencyLevel * verificationsPerClient;
|
||||
|
||||
var mockDsseVerifier = CreateMockDsseVerifier();
|
||||
var mockIdRecomputer = CreateMockIdRecomputer();
|
||||
var mockRekorVerifier = CreateMockRekorVerifier();
|
||||
var pipeline = new VerificationPipeline(
|
||||
mockDsseVerifier,
|
||||
mockIdRecomputer,
|
||||
mockRekorVerifier,
|
||||
_logger);
|
||||
|
||||
var results = new ConcurrentBag<VerificationResult>();
|
||||
var latencies = new ConcurrentBag<long>();
|
||||
|
||||
// Act: Run concurrent verifications
|
||||
var tasks = Enumerable.Range(0, concurrencyLevel)
|
||||
.Select(clientId => Task.Run(async () =>
|
||||
{
|
||||
for (var i = 0; i < verificationsPerClient; i++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var proof = GenerateSyntheticProof(clientId, i);
|
||||
var result = await pipeline.VerifyAsync(proof, CancellationToken.None);
|
||||
sw.Stop();
|
||||
latencies.Add(sw.ElapsedMilliseconds);
|
||||
results.Add(result);
|
||||
}
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: All verifications should be deterministic
|
||||
results.Count.Should().Be(totalVerifications);
|
||||
results.All(r => r.IsValid).Should().BeTrue("all synthetic proofs should verify successfully");
|
||||
|
||||
var avgLatency = latencies.Average();
|
||||
avgLatency.Should().BeLessThan(30, "verification should be fast");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Deterministic Ordering Tests Under Load
|
||||
|
||||
[Fact]
|
||||
public void ProofSpineOrdering_UnderConcurrency_RemainsDeterministic()
|
||||
{
|
||||
// Arrange: Same inputs should produce same outputs under concurrent access
|
||||
const int iterations = 100;
|
||||
var seed = 42;
|
||||
var random = new Random(seed);
|
||||
|
||||
var evidenceIds = Enumerable.Range(0, 5)
|
||||
.Select(i => $"sha256:{GenerateHash($"evidence{i}")}")
|
||||
.ToArray();
|
||||
|
||||
var results = new ConcurrentBag<string>();
|
||||
|
||||
// Act: Compute proof spine hash concurrently multiple times
|
||||
Parallel.For(0, iterations, _ =>
|
||||
{
|
||||
var sorted = evidenceIds.OrderBy(x => x).ToArray();
|
||||
var combined = string.Join(":", sorted);
|
||||
var hash = GenerateHash(combined);
|
||||
results.Add(hash);
|
||||
});
|
||||
|
||||
// Assert: All results should be identical (deterministic)
|
||||
results.Distinct().Count().Should().Be(1, "concurrent computations should be deterministic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MerkleTree_ConcurrentBuilding_ProducesSameRoot()
|
||||
{
|
||||
// Arrange
|
||||
const int leafCount = 1000;
|
||||
const int iterations = 20;
|
||||
|
||||
var leaves = Enumerable.Range(0, leafCount)
|
||||
.Select(i => Encoding.UTF8.GetBytes($"leaf-{i:D5}"))
|
||||
.ToList();
|
||||
|
||||
var roots = new ConcurrentBag<string>();
|
||||
|
||||
// Act: Build Merkle tree concurrently
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, iterations), async (_, ct) =>
|
||||
{
|
||||
var builder = new MerkleTreeBuilder();
|
||||
foreach (var leaf in leaves)
|
||||
{
|
||||
builder.AddLeaf(leaf);
|
||||
}
|
||||
var root = builder.ComputeRoot();
|
||||
roots.Add(Convert.ToHexString(root));
|
||||
});
|
||||
|
||||
// Assert: All roots should be identical
|
||||
roots.Distinct().Count().Should().Be(1, "Merkle tree root should be deterministic");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Throughput Benchmarks
|
||||
|
||||
[Theory]
|
||||
[InlineData(10, 100)] // Light load
|
||||
[InlineData(50, 50)] // Medium load
|
||||
[InlineData(100, 20)] // Heavy load
|
||||
public async Task ThroughputBenchmark_VariousLoadProfiles(int concurrency, int opsPerClient)
|
||||
{
|
||||
// Arrange
|
||||
var totalOps = concurrency * opsPerClient;
|
||||
var successCount = 0;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Act: Simulate API calls
|
||||
var tasks = Enumerable.Range(0, concurrency)
|
||||
.Select(_ => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < opsPerClient; i++)
|
||||
{
|
||||
// Simulate proof creation work
|
||||
var hash = GenerateHash($"proof-{Guid.NewGuid()}");
|
||||
Interlocked.Increment(ref successCount);
|
||||
}
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
stopwatch.Stop();
|
||||
|
||||
// Assert
|
||||
var throughput = successCount / stopwatch.Elapsed.TotalSeconds;
|
||||
successCount.Should().Be(totalOps);
|
||||
throughput.Should().BeGreaterThan(1000, $"throughput at {concurrency} concurrency should exceed 1000 ops/sec");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LatencyDistribution_UnderLoad_MeetsSloBudgets()
|
||||
{
|
||||
// Arrange: Define SLO budgets
|
||||
const double maxP50Ms = 10;
|
||||
const double maxP90Ms = 25;
|
||||
const double maxP99Ms = 100;
|
||||
const int sampleSize = 1000;
|
||||
|
||||
var latencies = new ConcurrentBag<double>();
|
||||
|
||||
// Act: Collect latency samples
|
||||
await Parallel.ForEachAsync(Enumerable.Range(0, sampleSize), async (i, ct) =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
// Simulate verification work
|
||||
var hash = GenerateHash($"sample-{i}");
|
||||
await Task.Delay(1, ct); // Simulate I/O
|
||||
sw.Stop();
|
||||
latencies.Add(sw.Elapsed.TotalMilliseconds);
|
||||
});
|
||||
|
||||
// Calculate percentiles
|
||||
var sorted = latencies.OrderBy(x => x).ToList();
|
||||
var p50 = CalculatePercentileFromSorted(sorted, 50);
|
||||
var p90 = CalculatePercentileFromSorted(sorted, 90);
|
||||
var p99 = CalculatePercentileFromSorted(sorted, 99);
|
||||
|
||||
// Assert: SLO compliance
|
||||
p50.Should().BeLessThan(maxP50Ms, "p50 latency should meet SLO");
|
||||
p90.Should().BeLessThan(maxP90Ms, "p90 latency should meet SLO");
|
||||
p99.Should().BeLessThan(maxP99Ms, "p99 latency should meet SLO");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Memory and Resource Tests
|
||||
|
||||
[Fact]
|
||||
public void LargeProofBatch_DoesNotCauseMemorySpike()
|
||||
{
|
||||
// Arrange
|
||||
const int batchSize = 10_000;
|
||||
var initialMemory = GC.GetTotalMemory(true);
|
||||
|
||||
// Act: Create large batch of proofs
|
||||
var proofs = new List<string>(batchSize);
|
||||
for (var i = 0; i < batchSize; i++)
|
||||
{
|
||||
var proof = GenerateSyntheticProofJson(i);
|
||||
proofs.Add(proof);
|
||||
}
|
||||
|
||||
// Force GC and measure
|
||||
var peakMemory = GC.GetTotalMemory(false);
|
||||
proofs.Clear();
|
||||
GC.Collect();
|
||||
var finalMemory = GC.GetTotalMemory(true);
|
||||
|
||||
// Assert: Memory should not grow unbounded
|
||||
var memoryGrowth = peakMemory - initialMemory;
|
||||
var memoryRetained = finalMemory - initialMemory;
|
||||
|
||||
// Each proof is ~500 bytes, so 10k proofs ≈ 5MB is reasonable
|
||||
memoryGrowth.Should().BeLessThan(50_000_000, "memory growth should be bounded (~50MB max for 10k proofs)");
|
||||
memoryRetained.Should().BeLessThan(10_000_000, "memory should be released after clearing");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static IProofSpineBuilder CreateTestProofSpineBuilder()
|
||||
{
|
||||
// Create a mock proof spine builder for load testing
|
||||
var builder = Substitute.For<IProofSpineBuilder>();
|
||||
builder.BuildAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string[]>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var entryId = callInfo.ArgAt<string>(0);
|
||||
return Task.FromResult(new ProofSpine
|
||||
{
|
||||
EntryId = entryId,
|
||||
SpineId = $"sha256:{GenerateHash(entryId)}",
|
||||
PolicyVersion = callInfo.ArgAt<string>(4),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IDsseVerifier CreateMockDsseVerifier()
|
||||
{
|
||||
var verifier = Substitute.For<IDsseVerifier>();
|
||||
verifier.VerifyAsync(Arg.Any<DsseEnvelope>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new DsseVerificationResult { IsValid = true }));
|
||||
return verifier;
|
||||
}
|
||||
|
||||
private static IIdRecomputer CreateMockIdRecomputer()
|
||||
{
|
||||
var recomputer = Substitute.For<IIdRecomputer>();
|
||||
recomputer.VerifyAsync(Arg.Any<ProofBundle>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new IdVerificationResult { IsValid = true }));
|
||||
return recomputer;
|
||||
}
|
||||
|
||||
private static IRekorVerifier CreateMockRekorVerifier()
|
||||
{
|
||||
var verifier = Substitute.For<IRekorVerifier>();
|
||||
verifier.VerifyInclusionAsync(Arg.Any<RekorEntry>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new RekorVerificationResult { IsValid = true }));
|
||||
return verifier;
|
||||
}
|
||||
|
||||
private static string GenerateSyntheticEntryId(int clientId, int index)
|
||||
{
|
||||
var hash = GenerateHash($"entry-{clientId}-{index}");
|
||||
return $"sha256:{hash}:pkg:npm/example@1.0.{index}";
|
||||
}
|
||||
|
||||
private static string[] GenerateSyntheticEvidenceIds(int count)
|
||||
{
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(i => $"sha256:{GenerateHash($"evidence-{i}")}")
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static ProofBundle GenerateSyntheticProof(int clientId, int index)
|
||||
{
|
||||
return new ProofBundle
|
||||
{
|
||||
EntryId = GenerateSyntheticEntryId(clientId, index),
|
||||
Envelope = new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.proof+json",
|
||||
Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{{\"id\":\"{clientId}-{index}\"}}")),
|
||||
Signatures = new[]
|
||||
{
|
||||
new DsseSignature
|
||||
{
|
||||
KeyId = "test-key",
|
||||
Sig = Convert.ToBase64String(Encoding.UTF8.GetBytes("test-signature"))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSyntheticProofJson(int index)
|
||||
{
|
||||
return $@"{{
|
||||
""entryId"": ""sha256:{GenerateHash($"entry-{index}")}:pkg:npm/example@1.0.{index}"",
|
||||
""spineId"": ""sha256:{GenerateHash($"spine-{index}")}"",
|
||||
""evidenceIds"": [""{GenerateHash($"ev1-{index}")}"", ""{GenerateHash($"ev2-{index}")}""],
|
||||
""reasoningId"": ""sha256:{GenerateHash($"reason-{index}")}"",
|
||||
""vexVerdictId"": ""sha256:{GenerateHash($"vex-{index}")}"",
|
||||
""policyVersion"": ""v2.3.1"",
|
||||
""createdAt"": ""{DateTimeOffset.UtcNow:O}""
|
||||
}}";
|
||||
}
|
||||
|
||||
private static string GenerateHash(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static double CalculatePercentile(ConcurrentBag<long> values, int percentile)
|
||||
{
|
||||
if (!values.Any()) return 0;
|
||||
var sorted = values.OrderBy(x => x).ToList();
|
||||
return CalculatePercentileFromSorted(sorted.Select(x => (double)x).ToList(), percentile);
|
||||
}
|
||||
|
||||
private static double CalculatePercentileFromSorted<T>(List<T> sorted, int percentile) where T : IConvertible
|
||||
{
|
||||
if (sorted.Count == 0) return 0;
|
||||
var index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
|
||||
index = Math.Max(0, Math.Min(index, sorted.Count - 1));
|
||||
return sorted[index].ToDouble(null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Supporting Types for Load Tests
|
||||
|
||||
/// <summary>
|
||||
/// Interface for proof spine building (mock target for load tests).
|
||||
/// </summary>
|
||||
public interface IProofSpineBuilder
|
||||
{
|
||||
Task<ProofSpine> BuildAsync(
|
||||
string entryId,
|
||||
string[] evidenceIds,
|
||||
string reasoningId,
|
||||
string vexVerdictId,
|
||||
string policyVersion,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a proof spine created for an SBOM entry.
|
||||
/// </summary>
|
||||
public class ProofSpine
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required string SpineId { get; init; }
|
||||
public required string PolicyVersion { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for DSSE envelope verification.
|
||||
/// </summary>
|
||||
public interface IDsseVerifier
|
||||
{
|
||||
Task<DsseVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE verification result.
|
||||
/// </summary>
|
||||
public class DsseVerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ID recomputation verification.
|
||||
/// </summary>
|
||||
public interface IIdRecomputer
|
||||
{
|
||||
Task<IdVerificationResult> VerifyAsync(ProofBundle bundle, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ID verification result.
|
||||
/// </summary>
|
||||
public class IdVerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string? ExpectedId { get; init; }
|
||||
public string? ActualId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Rekor inclusion proof verification.
|
||||
/// </summary>
|
||||
public interface IRekorVerifier
|
||||
{
|
||||
Task<RekorVerificationResult> VerifyInclusionAsync(RekorEntry entry, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor verification result.
|
||||
/// </summary>
|
||||
public class RekorVerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public long? LogIndex { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Rekor transparency log entry.
|
||||
/// </summary>
|
||||
public class RekorEntry
|
||||
{
|
||||
public long LogIndex { get; init; }
|
||||
public string? LogId { get; init; }
|
||||
public string? Body { get; init; }
|
||||
public DateTimeOffset IntegratedTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for proof bundles.
|
||||
/// </summary>
|
||||
public class DsseEnvelope
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required DsseSignature[] Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature within an envelope.
|
||||
/// </summary>
|
||||
public class DsseSignature
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete proof bundle for verification.
|
||||
/// </summary>
|
||||
public class ProofBundle
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required DsseEnvelope Envelope { get; init; }
|
||||
public RekorEntry? RekorEntry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete verification result from the pipeline.
|
||||
/// </summary>
|
||||
public class VerificationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public DsseVerificationResult? DsseResult { get; init; }
|
||||
public IdVerificationResult? IdResult { get; init; }
|
||||
public RekorVerificationResult? RekorResult { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification pipeline that runs all verification steps.
|
||||
/// </summary>
|
||||
public class VerificationPipeline
|
||||
{
|
||||
private readonly IDsseVerifier _dsseVerifier;
|
||||
private readonly IIdRecomputer _idRecomputer;
|
||||
private readonly IRekorVerifier _rekorVerifier;
|
||||
private readonly ILogger<VerificationPipeline> _logger;
|
||||
|
||||
public VerificationPipeline(
|
||||
IDsseVerifier dsseVerifier,
|
||||
IIdRecomputer idRecomputer,
|
||||
IRekorVerifier rekorVerifier,
|
||||
ILogger<VerificationPipeline> logger)
|
||||
{
|
||||
_dsseVerifier = dsseVerifier;
|
||||
_idRecomputer = idRecomputer;
|
||||
_rekorVerifier = rekorVerifier;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<VerificationResult> VerifyAsync(ProofBundle bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
// Step 1: DSSE signature verification
|
||||
var dsseResult = await _dsseVerifier.VerifyAsync(bundle.Envelope, cancellationToken);
|
||||
if (!dsseResult.IsValid)
|
||||
{
|
||||
return new VerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
DsseResult = dsseResult,
|
||||
Error = $"DSSE verification failed: {dsseResult.Error}"
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: ID recomputation
|
||||
var idResult = await _idRecomputer.VerifyAsync(bundle, cancellationToken);
|
||||
if (!idResult.IsValid)
|
||||
{
|
||||
return new VerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
DsseResult = dsseResult,
|
||||
IdResult = idResult,
|
||||
Error = $"ID mismatch: expected {idResult.ExpectedId}, got {idResult.ActualId}"
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Rekor inclusion (if entry present)
|
||||
RekorVerificationResult? rekorResult = null;
|
||||
if (bundle.RekorEntry != null)
|
||||
{
|
||||
rekorResult = await _rekorVerifier.VerifyInclusionAsync(bundle.RekorEntry, cancellationToken);
|
||||
if (!rekorResult.IsValid)
|
||||
{
|
||||
return new VerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
DsseResult = dsseResult,
|
||||
IdResult = idResult,
|
||||
RekorResult = rekorResult,
|
||||
Error = $"Rekor verification failed: {rekorResult.Error}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new VerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
DsseResult = dsseResult,
|
||||
IdResult = idResult,
|
||||
RekorResult = rekorResult
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -13,7 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-preview.7.24407.12" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationPipelineIntegrationTests.cs
|
||||
// Sprint: SPRINT_0501_0001_0001_proof_evidence_chain_master
|
||||
// Task: PROOF-MASTER-0002
|
||||
// Description: Integration tests for the full proof chain verification pipeline
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the verification pipeline.
|
||||
/// Tests PROOF-MASTER-0002: Full proof chain verification flow.
|
||||
/// </summary>
|
||||
public class VerificationPipelineIntegrationTests
|
||||
{
|
||||
private readonly IProofBundleStore _proofStore;
|
||||
private readonly IDsseVerifier _dsseVerifier;
|
||||
private readonly IRekorVerifier _rekorVerifier;
|
||||
private readonly ITrustAnchorResolver _trustAnchorResolver;
|
||||
private readonly ILogger<VerificationPipeline> _logger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public VerificationPipelineIntegrationTests()
|
||||
{
|
||||
_proofStore = Substitute.For<IProofBundleStore>();
|
||||
_dsseVerifier = Substitute.For<IDsseVerifier>();
|
||||
_rekorVerifier = Substitute.For<IRekorVerifier>();
|
||||
_trustAnchorResolver = Substitute.For<ITrustAnchorResolver>();
|
||||
_logger = NullLogger<VerificationPipeline>.Instance;
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 17, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
#region Full Pipeline Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidProofBundle_AllStepsPass()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:valid123");
|
||||
var keyId = "key-1";
|
||||
|
||||
SetupValidBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidRekorVerification();
|
||||
SetupValidTrustAnchor(keyId);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = true,
|
||||
VerifierVersion = "1.0.0-test"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Receipt.Result.Should().Be(VerificationResult.Pass);
|
||||
result.Steps.Should().HaveCount(4);
|
||||
result.Steps.Should().OnlyContain(s => s.Passed);
|
||||
result.FirstFailure.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_InvalidDsseSignature_FailsAtFirstStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:invalid-sig");
|
||||
var keyId = "key-1";
|
||||
|
||||
SetupValidBundle(bundleId, keyId);
|
||||
SetupInvalidDsseVerification(keyId, "Signature mismatch");
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Receipt.Result.Should().Be(VerificationResult.Fail);
|
||||
result.FirstFailure.Should().NotBeNull();
|
||||
result.FirstFailure!.StepName.Should().Be("dsse_signature");
|
||||
result.Receipt.FailureReason.Should().Contain("Signature mismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_IdMismatch_FailsAtIdRecomputation()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:wrong-id");
|
||||
var keyId = "key-1";
|
||||
|
||||
SetupBundleWithWrongId(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Steps.Should().Contain(s => s.StepName == "id_recomputation" && !s.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_NoRekorEntry_FailsAtRekorStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:no-rekor");
|
||||
var keyId = "key-1";
|
||||
|
||||
SetupBundleWithoutRekor(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Steps.Should().Contain(s => s.StepName == "rekor_inclusion" && !s.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RekorDisabled_SkipsRekorStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:skip-rekor");
|
||||
var keyId = "key-1";
|
||||
|
||||
SetupBundleWithoutRekor(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidTrustAnchor(keyId);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false // Skip Rekor
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
var rekorStep = result.Steps.FirstOrDefault(s => s.StepName == "rekor_inclusion");
|
||||
rekorStep.Should().NotBeNull();
|
||||
rekorStep!.Passed.Should().BeTrue();
|
||||
rekorStep.Details.Should().Contain("skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UnauthorizedKey_FailsAtTrustAnchor()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:bad-key");
|
||||
var keyId = "unauthorized-key";
|
||||
|
||||
SetupValidBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidRekorVerification();
|
||||
SetupTrustAnchorWithoutKey(keyId);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Steps.Should().Contain(s => s.StepName == "trust_anchor" && !s.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Receipt Generation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_GeneratesReceipt_WithCorrectFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:receipt-test");
|
||||
var keyId = "key-1";
|
||||
|
||||
SetupValidBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidRekorVerification();
|
||||
SetupValidTrustAnchor(keyId);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifierVersion = "2.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Receipt.Should().NotBeNull();
|
||||
result.Receipt.ReceiptId.Should().StartWith("receipt:");
|
||||
result.Receipt.VerifierVersion.Should().Be("2.0.0");
|
||||
result.Receipt.ProofBundleId.Should().Be(bundleId.Value);
|
||||
result.Receipt.StepsSummary.Should().HaveCount(4);
|
||||
result.Receipt.TotalDurationMs.Should().BeGreaterOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailingPipeline_ReceiptContainsFailureReason()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:fail-receipt");
|
||||
|
||||
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
|
||||
.Returns((ProofBundle?)null);
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Receipt.Result.Should().Be(VerificationResult.Fail);
|
||||
result.Receipt.FailureReason.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancellation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Cancelled_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = new ProofBundleId("sha256:cancel-test");
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var pipeline = CreatePipeline();
|
||||
var request = new VerificationPipelineRequest { ProofBundleId = bundleId };
|
||||
|
||||
// Act
|
||||
var result = await pipeline.VerifyAsync(request, cts.Token);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Steps.Should().Contain(s => s.ErrorMessage?.Contains("cancelled") == true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private VerificationPipeline CreatePipeline()
|
||||
{
|
||||
return VerificationPipeline.CreateDefault(
|
||||
_proofStore,
|
||||
_dsseVerifier,
|
||||
_rekorVerifier,
|
||||
_trustAnchorResolver,
|
||||
_logger,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
private void SetupValidBundle(ProofBundleId bundleId, string keyId)
|
||||
{
|
||||
var bundle = CreateTestBundle(keyId, includeRekor: true);
|
||||
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
|
||||
.Returns(bundle);
|
||||
}
|
||||
|
||||
private void SetupBundleWithWrongId(ProofBundleId bundleId, string keyId)
|
||||
{
|
||||
// Create a bundle but the ID won't match when recomputed
|
||||
var bundle = new ProofBundle
|
||||
{
|
||||
Statements = new List<ProofStatement>
|
||||
{
|
||||
new ProofStatement
|
||||
{
|
||||
StatementId = "sha256:wrong-statement-id", // Won't match content
|
||||
PredicateType = "evidence.stella/v1",
|
||||
Predicate = new { test = "data" }
|
||||
}
|
||||
},
|
||||
Envelopes = new List<DsseEnvelope>
|
||||
{
|
||||
new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = "test"u8.ToArray(),
|
||||
Signatures = new List<DsseSignature>
|
||||
{
|
||||
new DsseSignature { KeyId = keyId, Sig = new byte[] { 0x01 } }
|
||||
}
|
||||
}
|
||||
},
|
||||
RekorLogEntry = CreateTestRekorEntry()
|
||||
};
|
||||
|
||||
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
|
||||
.Returns(bundle);
|
||||
}
|
||||
|
||||
private void SetupBundleWithoutRekor(ProofBundleId bundleId, string keyId)
|
||||
{
|
||||
var bundle = CreateTestBundle(keyId, includeRekor: false);
|
||||
_proofStore.GetBundleAsync(bundleId, Arg.Any<CancellationToken>())
|
||||
.Returns(bundle);
|
||||
}
|
||||
|
||||
private void SetupValidDsseVerification(string keyId)
|
||||
{
|
||||
_dsseVerifier.VerifyAsync(Arg.Any<DsseEnvelope>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new DsseVerificationResult { IsValid = true, KeyId = keyId });
|
||||
}
|
||||
|
||||
private void SetupInvalidDsseVerification(string keyId, string error)
|
||||
{
|
||||
_dsseVerifier.VerifyAsync(Arg.Any<DsseEnvelope>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
KeyId = keyId,
|
||||
ErrorMessage = error
|
||||
});
|
||||
}
|
||||
|
||||
private void SetupValidRekorVerification()
|
||||
{
|
||||
_rekorVerifier.VerifyInclusionAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<long>(),
|
||||
Arg.Any<InclusionProof>(),
|
||||
Arg.Any<SignedTreeHead>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new RekorVerificationResult { IsValid = true });
|
||||
}
|
||||
|
||||
private void SetupValidTrustAnchor(string keyId)
|
||||
{
|
||||
var anchor = new TrustAnchorInfo
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
AllowedKeyIds = new List<string> { keyId },
|
||||
RevokedKeyIds = new List<string>()
|
||||
};
|
||||
|
||||
_trustAnchorResolver.GetAnchorAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(anchor);
|
||||
_trustAnchorResolver.FindAnchorForProofAsync(Arg.Any<ProofBundleId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(anchor);
|
||||
}
|
||||
|
||||
private void SetupTrustAnchorWithoutKey(string keyId)
|
||||
{
|
||||
var anchor = new TrustAnchorInfo
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
AllowedKeyIds = new List<string> { "different-key" },
|
||||
RevokedKeyIds = new List<string>()
|
||||
};
|
||||
|
||||
_trustAnchorResolver.FindAnchorForProofAsync(Arg.Any<ProofBundleId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(anchor);
|
||||
}
|
||||
|
||||
private static ProofBundle CreateTestBundle(string keyId, bool includeRekor)
|
||||
{
|
||||
return new ProofBundle
|
||||
{
|
||||
Statements = new List<ProofStatement>
|
||||
{
|
||||
new ProofStatement
|
||||
{
|
||||
StatementId = "sha256:test-statement",
|
||||
PredicateType = "evidence.stella/v1",
|
||||
Predicate = new { test = "data" }
|
||||
}
|
||||
},
|
||||
Envelopes = new List<DsseEnvelope>
|
||||
{
|
||||
new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = "test"u8.ToArray(),
|
||||
Signatures = new List<DsseSignature>
|
||||
{
|
||||
new DsseSignature { KeyId = keyId, Sig = new byte[] { 0x01 } }
|
||||
}
|
||||
}
|
||||
},
|
||||
RekorLogEntry = includeRekor ? CreateTestRekorEntry() : null
|
||||
};
|
||||
}
|
||||
|
||||
private static RekorLogEntry CreateTestRekorEntry()
|
||||
{
|
||||
return new RekorLogEntry
|
||||
{
|
||||
LogId = "test-log",
|
||||
LogIndex = 12345,
|
||||
InclusionProof = new InclusionProof
|
||||
{
|
||||
Hashes = new List<byte[]> { new byte[] { 0x01 } },
|
||||
TreeSize = 1000,
|
||||
RootHash = new byte[] { 0x02 }
|
||||
},
|
||||
SignedTreeHead = new SignedTreeHead
|
||||
{
|
||||
TreeSize = 1000,
|
||||
RootHash = new byte[] { 0x02 },
|
||||
Signature = new byte[] { 0x03 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset initialTime)
|
||||
{
|
||||
_now = initialTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
|
||||
public void SetTime(DateTimeOffset time) => _now = time;
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationPipelineTests.cs
|
||||
// Sprint: SPRINT_0501_0005_0001_proof_chain_api_surface
|
||||
// Task: PROOF-API-0011 - Integration tests for verification pipeline
|
||||
// Description: Tests for the full verification pipeline including DSSE, ID
|
||||
// recomputation, Rekor inclusion, and trust anchor verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.ProofChain.Identifiers;
|
||||
using StellaOps.Attestor.ProofChain.Receipts;
|
||||
using StellaOps.Attestor.ProofChain.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.ProofChain.Tests.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the verification pipeline.
|
||||
/// </summary>
|
||||
public class VerificationPipelineTests
|
||||
{
|
||||
private readonly Mock<IProofBundleStore> _proofStoreMock;
|
||||
private readonly Mock<IDsseVerifier> _dsseVerifierMock;
|
||||
private readonly Mock<IRekorVerifier> _rekorVerifierMock;
|
||||
private readonly Mock<ITrustAnchorResolver> _trustAnchorResolverMock;
|
||||
private readonly VerificationPipeline _pipeline;
|
||||
|
||||
public VerificationPipelineTests()
|
||||
{
|
||||
_proofStoreMock = new Mock<IProofBundleStore>();
|
||||
_dsseVerifierMock = new Mock<IDsseVerifier>();
|
||||
_rekorVerifierMock = new Mock<IRekorVerifier>();
|
||||
_trustAnchorResolverMock = new Mock<ITrustAnchorResolver>();
|
||||
|
||||
_pipeline = VerificationPipeline.CreateDefault(
|
||||
_proofStoreMock.Object,
|
||||
_dsseVerifierMock.Object,
|
||||
_rekorVerifierMock.Object,
|
||||
_trustAnchorResolverMock.Object,
|
||||
NullLogger<VerificationPipeline>.Instance);
|
||||
}
|
||||
|
||||
#region Full Pipeline Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_AllStepsPass_ReturnsValidResult()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "test-key-id";
|
||||
var anchorId = Guid.NewGuid();
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidRekorVerification();
|
||||
SetupValidTrustAnchor(anchorId, keyId);
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(VerificationResult.Pass, result.Receipt.Result);
|
||||
Assert.All(result.Steps, step => Assert.True(step.Passed));
|
||||
Assert.Null(result.FirstFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_DsseSignatureInvalid_FailsAtDsseStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "invalid-key";
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId);
|
||||
SetupInvalidDsseVerification("Signature verification failed");
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(VerificationResult.Fail, result.Receipt.Result);
|
||||
Assert.NotNull(result.FirstFailure);
|
||||
Assert.Equal("dsse_signature", result.FirstFailure.StepName);
|
||||
Assert.Contains("Signature verification failed", result.FirstFailure.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_IdMismatch_FailsAtIdRecomputationStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "test-key-id";
|
||||
|
||||
// Setup a bundle with mismatched ID
|
||||
SetupProofBundleWithMismatchedId(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
var idStep = result.Steps.FirstOrDefault(s => s.StepName == "id_recomputation");
|
||||
Assert.NotNull(idStep);
|
||||
// Note: The actual result depends on how the bundle is constructed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RekorInclusionFails_FailsAtRekorStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "test-key-id";
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupInvalidRekorVerification("Inclusion proof invalid");
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
var rekorStep = result.Steps.FirstOrDefault(s => s.StepName == "rekor_inclusion");
|
||||
Assert.NotNull(rekorStep);
|
||||
Assert.False(rekorStep.Passed);
|
||||
Assert.Contains("Inclusion proof invalid", rekorStep.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RekorDisabled_SkipsRekorStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "test-key-id";
|
||||
var anchorId = Guid.NewGuid();
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId, includeRekorEntry: false);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidTrustAnchor(anchorId, keyId);
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
var rekorStep = result.Steps.FirstOrDefault(s => s.StepName == "rekor_inclusion");
|
||||
Assert.NotNull(rekorStep);
|
||||
Assert.True(rekorStep.Passed);
|
||||
Assert.Contains("skipped", rekorStep.Details, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UnauthorizedKey_FailsAtTrustAnchorStep()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "unauthorized-key";
|
||||
var anchorId = Guid.NewGuid();
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupTrustAnchorWithoutKey(anchorId, keyId);
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
var anchorStep = result.Steps.FirstOrDefault(s => s.StepName == "trust_anchor");
|
||||
Assert.NotNull(anchorStep);
|
||||
Assert.False(anchorStep.Passed);
|
||||
Assert.Contains("not authorized", anchorStep.ErrorMessage);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Receipt Generation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_GeneratesReceiptWithCorrectFields()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "test-key-id";
|
||||
var anchorId = Guid.NewGuid();
|
||||
var verifierVersion = "2.0.0";
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId);
|
||||
SetupValidDsseVerification(keyId);
|
||||
SetupValidRekorVerification();
|
||||
SetupValidTrustAnchor(anchorId, keyId);
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = true,
|
||||
VerifierVersion = verifierVersion
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Receipt);
|
||||
Assert.NotEmpty(result.Receipt.ReceiptId);
|
||||
Assert.Equal(bundleId.Value, result.Receipt.ProofBundleId);
|
||||
Assert.Equal(verifierVersion, result.Receipt.VerifierVersion);
|
||||
Assert.True(result.Receipt.TotalDurationMs >= 0);
|
||||
Assert.NotEmpty(result.Receipt.StepsSummary!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailedVerification_ReceiptContainsFailureReason()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
|
||||
_proofStoreMock
|
||||
.Setup(x => x.GetBundleAsync(bundleId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ProofBundle?)null);
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _pipeline.VerifyAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(VerificationResult.Fail, result.Receipt.Result);
|
||||
Assert.NotNull(result.Receipt.FailureReason);
|
||||
Assert.Contains("not found", result.Receipt.FailureReason);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancellation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_Cancelled_ReturnsPartialResults()
|
||||
{
|
||||
// Arrange
|
||||
var bundleId = CreateTestBundleId();
|
||||
var keyId = "test-key-id";
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
SetupValidProofBundle(bundleId, keyId);
|
||||
|
||||
// Setup DSSE verification to cancel
|
||||
_dsseVerifierMock
|
||||
.Setup(x => x.VerifyAsync(It.IsAny<DsseEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (DsseEnvelope _, CancellationToken ct) =>
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return new DsseVerificationResult { IsValid = true, KeyId = keyId };
|
||||
});
|
||||
|
||||
var request = new VerificationPipelineRequest
|
||||
{
|
||||
ProofBundleId = bundleId,
|
||||
VerifyRekor = false
|
||||
};
|
||||
|
||||
// Act & Assert - should complete but show cancellation
|
||||
// The actual behavior depends on implementation
|
||||
var result = await _pipeline.VerifyAsync(request, cts.Token);
|
||||
// Pipeline may handle cancellation gracefully
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ProofBundleId CreateTestBundleId()
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString()));
|
||||
return new ProofBundleId($"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
private void SetupValidProofBundle(ProofBundleId bundleId, string keyId, bool includeRekorEntry = true)
|
||||
{
|
||||
var bundle = new ProofBundle
|
||||
{
|
||||
Statements = new List<ProofStatement>
|
||||
{
|
||||
new ProofStatement
|
||||
{
|
||||
StatementId = "sha256:statement123",
|
||||
PredicateType = "https://stella-ops.io/v1/evidence",
|
||||
Predicate = new { test = "data" }
|
||||
}
|
||||
},
|
||||
Envelopes = new List<DsseEnvelope>
|
||||
{
|
||||
new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Encoding.UTF8.GetBytes("{}"),
|
||||
Signatures = new List<DsseSignature>
|
||||
{
|
||||
new DsseSignature { KeyId = keyId, Sig = new byte[64] }
|
||||
}
|
||||
}
|
||||
},
|
||||
RekorLogEntry = includeRekorEntry ? new RekorLogEntry
|
||||
{
|
||||
LogId = "test-log",
|
||||
LogIndex = 12345,
|
||||
InclusionProof = new InclusionProof
|
||||
{
|
||||
Hashes = new List<byte[]>(),
|
||||
TreeSize = 100,
|
||||
RootHash = new byte[32]
|
||||
},
|
||||
SignedTreeHead = new SignedTreeHead
|
||||
{
|
||||
TreeSize = 100,
|
||||
RootHash = new byte[32],
|
||||
Signature = new byte[64]
|
||||
}
|
||||
} : null
|
||||
};
|
||||
|
||||
_proofStoreMock
|
||||
.Setup(x => x.GetBundleAsync(bundleId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundle);
|
||||
}
|
||||
|
||||
private void SetupProofBundleWithMismatchedId(ProofBundleId bundleId, string keyId)
|
||||
{
|
||||
// Create a bundle that will compute to a different ID
|
||||
var bundle = new ProofBundle
|
||||
{
|
||||
Statements = new List<ProofStatement>
|
||||
{
|
||||
new ProofStatement
|
||||
{
|
||||
StatementId = "sha256:differentstatement",
|
||||
PredicateType = "https://stella-ops.io/v1/evidence",
|
||||
Predicate = new { different = "data" }
|
||||
}
|
||||
},
|
||||
Envelopes = new List<DsseEnvelope>
|
||||
{
|
||||
new DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Encoding.UTF8.GetBytes("{\"different\":\"payload\"}"),
|
||||
Signatures = new List<DsseSignature>
|
||||
{
|
||||
new DsseSignature { KeyId = keyId, Sig = new byte[64] }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_proofStoreMock
|
||||
.Setup(x => x.GetBundleAsync(bundleId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundle);
|
||||
}
|
||||
|
||||
private void SetupValidDsseVerification(string keyId)
|
||||
{
|
||||
_dsseVerifierMock
|
||||
.Setup(x => x.VerifyAsync(It.IsAny<DsseEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseVerificationResult { IsValid = true, KeyId = keyId });
|
||||
}
|
||||
|
||||
private void SetupInvalidDsseVerification(string errorMessage)
|
||||
{
|
||||
_dsseVerifierMock
|
||||
.Setup(x => x.VerifyAsync(It.IsAny<DsseEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
KeyId = "unknown",
|
||||
ErrorMessage = errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
private void SetupValidRekorVerification()
|
||||
{
|
||||
_rekorVerifierMock
|
||||
.Setup(x => x.VerifyInclusionAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<InclusionProof>(),
|
||||
It.IsAny<SignedTreeHead>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RekorVerificationResult { IsValid = true });
|
||||
}
|
||||
|
||||
private void SetupInvalidRekorVerification(string errorMessage)
|
||||
{
|
||||
_rekorVerifierMock
|
||||
.Setup(x => x.VerifyInclusionAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<long>(),
|
||||
It.IsAny<InclusionProof>(),
|
||||
It.IsAny<SignedTreeHead>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RekorVerificationResult { IsValid = false, ErrorMessage = errorMessage });
|
||||
}
|
||||
|
||||
private void SetupValidTrustAnchor(Guid anchorId, string keyId)
|
||||
{
|
||||
var anchor = new TrustAnchorInfo
|
||||
{
|
||||
AnchorId = anchorId,
|
||||
AllowedKeyIds = new List<string> { keyId },
|
||||
RevokedKeyIds = new List<string>()
|
||||
};
|
||||
|
||||
_trustAnchorResolverMock
|
||||
.Setup(x => x.FindAnchorForProofAsync(It.IsAny<ProofBundleId>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(anchor);
|
||||
|
||||
_trustAnchorResolverMock
|
||||
.Setup(x => x.GetAnchorAsync(anchorId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(anchor);
|
||||
}
|
||||
|
||||
private void SetupTrustAnchorWithoutKey(Guid anchorId, string keyId)
|
||||
{
|
||||
var anchor = new TrustAnchorInfo
|
||||
{
|
||||
AnchorId = anchorId,
|
||||
AllowedKeyIds = new List<string> { "other-key-not-matching" },
|
||||
RevokedKeyIds = new List<string>()
|
||||
};
|
||||
|
||||
_trustAnchorResolverMock
|
||||
.Setup(x => x.FindAnchorForProofAsync(It.IsAny<ProofBundleId>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(anchor);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user