Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,336 @@
// -----------------------------------------------------------------------------
// AttestationBundlerTests.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0018-0020 - Unit tests for bundling
// Description: Unit tests for AttestationBundler service
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Configuration;
using StellaOps.Attestor.Bundling.Models;
using StellaOps.Attestor.Bundling.Services;
using StellaOps.Attestor.ProofChain.Merkle;
namespace StellaOps.Attestor.Bundling.Tests;
public class AttestationBundlerTests
{
private readonly Mock<IBundleAggregator> _aggregatorMock;
private readonly Mock<IBundleStore> _storeMock;
private readonly Mock<IOrgKeySigner> _orgSignerMock;
private readonly IMerkleTreeBuilder _merkleBuilder;
private readonly Mock<ILogger<AttestationBundler>> _loggerMock;
private readonly IOptions<BundlingOptions> _options;
public AttestationBundlerTests()
{
_aggregatorMock = new Mock<IBundleAggregator>();
_storeMock = new Mock<IBundleStore>();
_orgSignerMock = new Mock<IOrgKeySigner>();
_merkleBuilder = new DeterministicMerkleTreeBuilder();
_loggerMock = new Mock<ILogger<AttestationBundler>>();
_options = Options.Create(new BundlingOptions());
}
[Fact]
public async Task CreateBundleAsync_WithAttestations_CreatesDeterministicBundle()
{
// Arrange
var attestations = CreateTestAttestations(5);
SetupAggregator(attestations);
var bundler = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow);
// Act
var bundle = await bundler.CreateBundleAsync(request);
// Assert
bundle.Should().NotBeNull();
bundle.Attestations.Should().HaveCount(5);
bundle.MerkleTree.LeafCount.Should().Be(5);
bundle.MerkleTree.Root.Should().StartWith("sha256:");
bundle.Metadata.BundleId.Should().Be(bundle.MerkleTree.Root);
}
[Fact]
public async Task CreateBundleAsync_SameAttestationsShuffled_SameMerkleRoot()
{
// Arrange
var attestations = CreateTestAttestations(10);
// Create two bundlers with attestations in different orders
var shuffled1 = attestations.OrderBy(_ => Guid.NewGuid()).ToList();
var shuffled2 = attestations.OrderBy(_ => Guid.NewGuid()).ToList();
SetupAggregator(shuffled1);
var bundler1 = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow);
var bundle1 = await bundler1.CreateBundleAsync(request);
// Reset and use different order
SetupAggregator(shuffled2);
var bundler2 = CreateBundler();
var bundle2 = await bundler2.CreateBundleAsync(request);
// Assert - same merkle root regardless of input order
bundle1.MerkleTree.Root.Should().Be(bundle2.MerkleTree.Root);
bundle1.Metadata.BundleId.Should().Be(bundle2.Metadata.BundleId);
}
[Fact]
public async Task CreateBundleAsync_NoAttestations_ThrowsException()
{
// Arrange
SetupAggregator(new List<BundledAttestation>());
var bundler = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => bundler.CreateBundleAsync(request));
}
[Fact]
public async Task CreateBundleAsync_WithOrgSigning_SignsBundle()
{
// Arrange
var attestations = CreateTestAttestations(3);
SetupAggregator(attestations);
var expectedSignature = new OrgSignature
{
KeyId = "org-key-2025",
Algorithm = "ECDSA_P256",
Signature = Convert.ToBase64String(new byte[64]),
SignedAt = DateTimeOffset.UtcNow,
CertificateChain = null
};
_orgSignerMock
.Setup(x => x.GetActiveKeyIdAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync("org-key-2025");
_orgSignerMock
.Setup(x => x.SignBundleAsync(It.IsAny<byte[]>(), "org-key-2025", It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedSignature);
var bundler = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow,
SignWithOrgKey: true);
// Act
var bundle = await bundler.CreateBundleAsync(request);
// Assert
bundle.OrgSignature.Should().NotBeNull();
bundle.OrgSignature!.KeyId.Should().Be("org-key-2025");
bundle.OrgSignature.Algorithm.Should().Be("ECDSA_P256");
}
[Fact]
public async Task VerifyBundleAsync_ValidBundle_ReturnsValid()
{
// Arrange
var attestations = CreateTestAttestations(5);
SetupAggregator(attestations);
var bundler = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow);
var bundle = await bundler.CreateBundleAsync(request);
// Act
var result = await bundler.VerifyBundleAsync(bundle);
// Assert
result.Valid.Should().BeTrue();
result.MerkleRootVerified.Should().BeTrue();
result.Issues.Should().BeEmpty();
}
[Fact]
public async Task VerifyBundleAsync_TamperedBundle_ReturnsMerkleRootMismatch()
{
// Arrange
var attestations = CreateTestAttestations(5);
SetupAggregator(attestations);
var bundler = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow);
var bundle = await bundler.CreateBundleAsync(request);
// Tamper with the bundle by modifying an attestation
var tamperedAttestations = bundle.Attestations.ToList();
var original = tamperedAttestations[0];
tamperedAttestations[0] = original with { EntryId = "tampered-entry-id" };
var tamperedBundle = bundle with { Attestations = tamperedAttestations };
// Act
var result = await bundler.VerifyBundleAsync(tamperedBundle);
// Assert
result.Valid.Should().BeFalse();
result.MerkleRootVerified.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code == "MERKLE_ROOT_MISMATCH");
}
[Fact]
public async Task CreateBundleAsync_RespectsTenantFilter()
{
// Arrange
var attestations = CreateTestAttestations(5);
SetupAggregator(attestations);
var bundler = CreateBundler();
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow,
TenantId: "test-tenant");
// Act
var bundle = await bundler.CreateBundleAsync(request);
// Assert
bundle.Metadata.TenantId.Should().Be("test-tenant");
_aggregatorMock.Verify(x => x.AggregateAsync(
It.Is<AggregationRequest>(r => r.TenantId == "test-tenant"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateBundleAsync_RespectsMaxAttestationsLimit()
{
// Arrange
var attestations = CreateTestAttestations(100);
SetupAggregator(attestations);
var options = Options.Create(new BundlingOptions
{
Aggregation = new BundleAggregationOptions
{
MaxAttestationsPerBundle = 10
}
});
var bundler = new AttestationBundler(
_aggregatorMock.Object,
_storeMock.Object,
_merkleBuilder,
_loggerMock.Object,
options,
_orgSignerMock.Object);
var request = new BundleCreationRequest(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow);
// Act
var bundle = await bundler.CreateBundleAsync(request);
// Assert
bundle.Attestations.Should().HaveCount(10);
}
private AttestationBundler CreateBundler()
{
return new AttestationBundler(
_aggregatorMock.Object,
_storeMock.Object,
_merkleBuilder,
_loggerMock.Object,
_options,
_orgSignerMock.Object);
}
private void SetupAggregator(List<BundledAttestation> attestations)
{
_aggregatorMock
.Setup(x => x.AggregateAsync(
It.IsAny<AggregationRequest>(),
It.IsAny<CancellationToken>()))
.Returns(attestations.ToAsyncEnumerable());
}
private static List<BundledAttestation> CreateTestAttestations(int count)
{
var attestations = new List<BundledAttestation>();
for (var i = 0; i < count; i++)
{
attestations.Add(new BundledAttestation
{
EntryId = $"entry-{i:D4}",
RekorUuid = Guid.NewGuid().ToString("N"),
RekorLogIndex = 10000 + i,
ArtifactDigest = $"sha256:{new string((char)('a' + i % 26), 64)}",
PredicateType = "verdict.stella/v1",
SignedAt = DateTimeOffset.UtcNow.AddHours(-i),
SigningMode = "keyless",
SigningIdentity = new SigningIdentity
{
Issuer = "https://authority.internal",
Subject = "signer@stella-ops.org",
San = "urn:stellaops:signer"
},
InclusionProof = new RekorInclusionProof
{
Checkpoint = new CheckpointData
{
Origin = "rekor.sigstore.dev",
Size = 100000 + i,
RootHash = Convert.ToBase64String(new byte[32]),
Timestamp = DateTimeOffset.UtcNow
},
Path = new List<string>
{
Convert.ToBase64String(new byte[32]),
Convert.ToBase64String(new byte[32])
}
},
Envelope = new DsseEnvelopeData
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
Signatures = new List<EnvelopeSignature>
{
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
},
CertificateChain = new List<string>
{
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
}
}
});
}
return attestations;
}
}

View File

@@ -0,0 +1,359 @@
// -----------------------------------------------------------------------------
// BundleAggregatorTests.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0018 - Unit tests: BundleAggregator
// Description: Unit tests for attestation aggregation with date range and tenant filtering
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Models;
using Xunit;
namespace StellaOps.Attestor.Bundling.Tests;
public class BundleAggregatorTests
{
private readonly InMemoryBundleAggregator _aggregator;
public BundleAggregatorTests()
{
_aggregator = new InMemoryBundleAggregator();
}
#region Date Range Filtering Tests
[Fact]
public async Task AggregateAsync_WithDateRange_ReturnsOnlyAttestationsInRange()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5))); // In range
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(15))); // In range
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(-5))); // Before range
_aggregator.AddAttestation(CreateAttestation("att-4", end.AddDays(5))); // After range
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(start, end))
.ToListAsync();
// Assert
results.Should().HaveCount(2);
results.Should().Contain(a => a.EntryId == "att-1");
results.Should().Contain(a => a.EntryId == "att-2");
results.Should().NotContain(a => a.EntryId == "att-3");
results.Should().NotContain(a => a.EntryId == "att-4");
}
[Fact]
public async Task AggregateAsync_InclusiveBoundaries_IncludesEdgeAttestations()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-start", start)); // Exactly at start
_aggregator.AddAttestation(CreateAttestation("att-end", end)); // Exactly at end
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(start, end))
.ToListAsync();
// Assert
results.Should().HaveCount(2);
results.Should().Contain(a => a.EntryId == "att-start");
results.Should().Contain(a => a.EntryId == "att-end");
}
[Fact]
public async Task AggregateAsync_EmptyRange_ReturnsEmpty()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
// Add attestations outside the range
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(-10)));
_aggregator.AddAttestation(CreateAttestation("att-2", end.AddDays(10)));
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(start, end))
.ToListAsync();
// Assert
results.Should().BeEmpty();
}
#endregion
#region Tenant Filtering Tests
[Fact]
public async Task AggregateAsync_WithTenantFilter_ReturnsOnlyTenantAttestations()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5)), tenantId: "tenant-a");
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10)), tenantId: "tenant-a");
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15)), tenantId: "tenant-b");
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(start, end, TenantId: "tenant-a"))
.ToListAsync();
// Assert
results.Should().HaveCount(2);
results.Should().OnlyContain(a => a.EntryId.StartsWith("att-1") || a.EntryId.StartsWith("att-2"));
}
[Fact]
public async Task AggregateAsync_WithoutTenantFilter_ReturnsAllTenants()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5)), tenantId: "tenant-a");
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10)), tenantId: "tenant-b");
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15)), tenantId: null);
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(start, end))
.ToListAsync();
// Assert
results.Should().HaveCount(3);
}
#endregion
#region Predicate Type Filtering Tests
[Fact]
public async Task AggregateAsync_WithPredicateTypes_ReturnsOnlyMatchingTypes()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5), predicateType: "verdict.stella/v1"));
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10), predicateType: "sbom.stella/v1"));
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15), predicateType: "verdict.stella/v1"));
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(
start, end,
PredicateTypes: new[] { "verdict.stella/v1" }))
.ToListAsync();
// Assert
results.Should().HaveCount(2);
results.Should().OnlyContain(a => a.PredicateType == "verdict.stella/v1");
}
[Fact]
public async Task AggregateAsync_WithMultiplePredicateTypes_ReturnsAllMatchingTypes()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5), predicateType: "verdict.stella/v1"));
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10), predicateType: "sbom.stella/v1"));
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15), predicateType: "provenance.stella/v1"));
// Act
var results = await _aggregator
.AggregateAsync(new AggregationRequest(
start, end,
PredicateTypes: new[] { "verdict.stella/v1", "sbom.stella/v1" }))
.ToListAsync();
// Assert
results.Should().HaveCount(2);
results.Should().NotContain(a => a.PredicateType == "provenance.stella/v1");
}
#endregion
#region Count Tests
[Fact]
public async Task CountAsync_ReturnsCorrectCount()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
for (int i = 0; i < 50; i++)
{
_aggregator.AddAttestation(CreateAttestation($"att-{i}", start.AddDays(i % 30)));
}
// Act
var count = await _aggregator.CountAsync(new AggregationRequest(start, end));
// Assert
count.Should().Be(50);
}
[Fact]
public async Task CountAsync_WithFilters_ReturnsFilteredCount()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateAttestation("att-1", start.AddDays(5)), tenantId: "tenant-a");
_aggregator.AddAttestation(CreateAttestation("att-2", start.AddDays(10)), tenantId: "tenant-a");
_aggregator.AddAttestation(CreateAttestation("att-3", start.AddDays(15)), tenantId: "tenant-b");
// Act
var count = await _aggregator.CountAsync(new AggregationRequest(start, end, TenantId: "tenant-a"));
// Assert
count.Should().Be(2);
}
#endregion
#region Ordering Tests
[Fact]
public async Task AggregateAsync_ReturnsDeterministicOrder()
{
// Arrange
var start = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
// Add in random order
_aggregator.AddAttestation(CreateAttestation("att-c", start.AddDays(15)));
_aggregator.AddAttestation(CreateAttestation("att-a", start.AddDays(5)));
_aggregator.AddAttestation(CreateAttestation("att-b", start.AddDays(10)));
// Act
var results1 = await _aggregator.AggregateAsync(new AggregationRequest(start, end)).ToListAsync();
var results2 = await _aggregator.AggregateAsync(new AggregationRequest(start, end)).ToListAsync();
// Assert: Order should be consistent (sorted by EntryId)
results1.Select(a => a.EntryId).Should().BeEquivalentTo(
results2.Select(a => a.EntryId),
options => options.WithStrictOrdering());
}
#endregion
#region Helper Methods
private static BundledAttestation CreateAttestation(
string entryId,
DateTimeOffset signedAt,
string? tenantId = null,
string predicateType = "verdict.stella/v1")
{
return new BundledAttestation
{
EntryId = entryId,
RekorUuid = $"rekor-{entryId}",
RekorLogIndex = Random.Shared.NextInt64(1000000),
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
PredicateType = predicateType,
SignedAt = signedAt,
SigningMode = "keyless",
SigningIdentity = new SigningIdentity
{
Issuer = "https://token.actions.githubusercontent.com",
Subject = "repo:org/repo:ref:refs/heads/main"
},
Envelope = new DsseEnvelopeData
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String("test-payload"u8.ToArray()),
Signatures = new List<EnvelopeSignature>
{
new() { Sig = Convert.ToBase64String("test-sig"u8.ToArray()) }
}
}
};
}
#endregion
}
/// <summary>
/// In-memory implementation of IBundleAggregator for testing.
/// </summary>
internal sealed class InMemoryBundleAggregator : IBundleAggregator
{
private readonly List<(BundledAttestation Attestation, string? TenantId)> _attestations = new();
public void AddAttestation(BundledAttestation attestation, string? tenantId = null)
{
_attestations.Add((attestation, tenantId));
}
public async IAsyncEnumerable<BundledAttestation> AggregateAsync(
AggregationRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var query = _attestations.AsEnumerable();
// Date range filter
query = query.Where(x =>
x.Attestation.SignedAt >= request.PeriodStart &&
x.Attestation.SignedAt <= request.PeriodEnd);
// Tenant filter
if (request.TenantId != null)
{
query = query.Where(x => x.TenantId == request.TenantId);
}
// Predicate type filter
if (request.PredicateTypes?.Count > 0)
{
query = query.Where(x => request.PredicateTypes.Contains(x.Attestation.PredicateType));
}
// Deterministic ordering
query = query.OrderBy(x => x.Attestation.EntryId);
foreach (var item in query)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return item.Attestation;
}
}
public Task<int> CountAsync(AggregationRequest request, CancellationToken cancellationToken = default)
{
var query = _attestations.AsEnumerable();
query = query.Where(x =>
x.Attestation.SignedAt >= request.PeriodStart &&
x.Attestation.SignedAt <= request.PeriodEnd);
if (request.TenantId != null)
{
query = query.Where(x => x.TenantId == request.TenantId);
}
if (request.PredicateTypes?.Count > 0)
{
query = query.Where(x => request.PredicateTypes.Contains(x.Attestation.PredicateType));
}
return Task.FromResult(query.Count());
}
}

View File

@@ -0,0 +1,508 @@
// -----------------------------------------------------------------------------
// BundleWorkflowIntegrationTests.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0023 - Integration test: Full bundle workflow
// Task: 0024 - Integration test: Scheduler job
// Description: Integration tests for complete bundle workflow and scheduler execution
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Configuration;
using StellaOps.Attestor.Bundling.Models;
using Xunit;
namespace StellaOps.Attestor.Bundling.Tests;
/// <summary>
/// Integration tests for the full bundle creation workflow:
/// Create → Store → Retrieve → Verify
/// </summary>
public class BundleWorkflowIntegrationTests
{
private readonly InMemoryBundleStore _store;
private readonly InMemoryBundleAggregator _aggregator;
private readonly TestOrgKeySigner _signer;
private readonly IOptions<BundlingOptions> _options;
public BundleWorkflowIntegrationTests()
{
_store = new InMemoryBundleStore();
_aggregator = new InMemoryBundleAggregator();
_signer = new TestOrgKeySigner();
_options = Options.Create(new BundlingOptions());
}
#region Full Workflow Tests
[Fact]
public async Task FullWorkflow_CreateStoreRetrieveVerify_Succeeds()
{
// Arrange: Add test attestations
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
for (int i = 0; i < 10; i++)
{
_aggregator.AddAttestation(CreateTestAttestation($"att-{i}", periodStart.AddDays(i)));
}
// Act 1: Create bundle
var createRequest = new BundleCreationRequest(
periodStart, periodEnd,
SignWithOrgKey: true,
OrgKeyId: "test-key");
var bundle = await CreateBundleAsync(createRequest);
// Assert: Bundle created correctly
bundle.Should().NotBeNull();
bundle.Metadata.AttestationCount.Should().Be(10);
bundle.OrgSignature.Should().NotBeNull();
// Act 2: Store bundle
await _store.StoreBundleAsync(bundle);
// Assert: Bundle exists
(await _store.ExistsAsync(bundle.Metadata.BundleId)).Should().BeTrue();
// Act 3: Retrieve bundle
var retrieved = await _store.GetBundleAsync(bundle.Metadata.BundleId);
// Assert: Retrieved bundle matches
retrieved.Should().NotBeNull();
retrieved!.Metadata.BundleId.Should().Be(bundle.Metadata.BundleId);
retrieved.Attestations.Should().HaveCount(10);
// Act 4: Verify bundle
var verificationResult = await VerifyBundleAsync(retrieved);
// Assert: Verification passes
verificationResult.Should().BeTrue();
}
[Fact]
public async Task FullWorkflow_WithoutOrgSignature_StillWorks()
{
// Arrange
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateTestAttestation("att-1", periodStart.AddDays(5)));
// Act: Create bundle WITHOUT org signature
var createRequest = new BundleCreationRequest(
periodStart, periodEnd,
SignWithOrgKey: false);
var bundle = await CreateBundleAsync(createRequest);
await _store.StoreBundleAsync(bundle);
var retrieved = await _store.GetBundleAsync(bundle.Metadata.BundleId);
// Assert
retrieved.Should().NotBeNull();
retrieved!.OrgSignature.Should().BeNull();
retrieved.Attestations.Should().HaveCount(1);
}
[Fact]
public async Task FullWorkflow_EmptyPeriod_CreatesEmptyBundle()
{
// Arrange: No attestations added
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
// Act
var createRequest = new BundleCreationRequest(periodStart, periodEnd);
var bundle = await CreateBundleAsync(createRequest);
// Assert
bundle.Metadata.AttestationCount.Should().Be(0);
bundle.Attestations.Should().BeEmpty();
}
[Fact]
public async Task FullWorkflow_LargeBundle_HandlesCorrectly()
{
// Arrange: Add many attestations
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
for (int i = 0; i < 1000; i++)
{
_aggregator.AddAttestation(CreateTestAttestation($"att-{i:D4}", periodStart.AddMinutes(i)));
}
// Act
var bundle = await CreateBundleAsync(new BundleCreationRequest(periodStart, periodEnd));
await _store.StoreBundleAsync(bundle);
var retrieved = await _store.GetBundleAsync(bundle.Metadata.BundleId);
// Assert
retrieved.Should().NotBeNull();
retrieved!.Attestations.Should().HaveCount(1000);
}
#endregion
#region Tenant Isolation Tests
[Fact]
public async Task FullWorkflow_TenantIsolation_CreatesSeperateBundles()
{
// Arrange
var periodStart = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = new DateTimeOffset(2025, 12, 31, 23, 59, 59, TimeSpan.Zero);
_aggregator.AddAttestation(CreateTestAttestation("att-a1", periodStart.AddDays(5)), "tenant-a");
_aggregator.AddAttestation(CreateTestAttestation("att-a2", periodStart.AddDays(10)), "tenant-a");
_aggregator.AddAttestation(CreateTestAttestation("att-b1", periodStart.AddDays(15)), "tenant-b");
// Act: Create bundles for each tenant
var bundleA = await CreateBundleAsync(new BundleCreationRequest(
periodStart, periodEnd, TenantId: "tenant-a"));
var bundleB = await CreateBundleAsync(new BundleCreationRequest(
periodStart, periodEnd, TenantId: "tenant-b"));
// Assert
bundleA.Attestations.Should().HaveCount(2);
bundleB.Attestations.Should().HaveCount(1);
bundleA.Metadata.BundleId.Should().NotBe(bundleB.Metadata.BundleId);
}
#endregion
#region Scheduler Job Tests
[Fact]
public async Task SchedulerJob_ExecutesAndCreatesBundles()
{
// Arrange: Add attestations for previous month
var now = DateTimeOffset.UtcNow;
var previousMonth = now.AddMonths(-1);
var periodStart = new DateTimeOffset(previousMonth.Year, previousMonth.Month, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = periodStart.AddMonths(1).AddTicks(-1);
for (int i = 0; i < 5; i++)
{
_aggregator.AddAttestation(CreateTestAttestation($"att-{i}", periodStart.AddDays(i * 5)));
}
// Act: Simulate scheduler job execution
var jobResult = await ExecuteRotationJobAsync(periodStart, periodEnd);
// Assert
jobResult.Success.Should().BeTrue();
jobResult.BundleId.Should().NotBeEmpty();
jobResult.AttestationCount.Should().Be(5);
// Verify bundle was stored
(await _store.ExistsAsync(jobResult.BundleId)).Should().BeTrue();
}
[Fact]
public async Task SchedulerJob_MultiTenant_CreatesBundlesForEachTenant()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var previousMonth = now.AddMonths(-1);
var periodStart = new DateTimeOffset(previousMonth.Year, previousMonth.Month, 1, 0, 0, 0, TimeSpan.Zero);
var periodEnd = periodStart.AddMonths(1).AddTicks(-1);
_aggregator.AddAttestation(CreateTestAttestation("att-1", periodStart.AddDays(5)), "tenant-x");
_aggregator.AddAttestation(CreateTestAttestation("att-2", periodStart.AddDays(10)), "tenant-y");
// Act: Execute job for all tenants
var resultX = await ExecuteRotationJobAsync(periodStart, periodEnd, "tenant-x");
var resultY = await ExecuteRotationJobAsync(periodStart, periodEnd, "tenant-y");
// Assert
resultX.Success.Should().BeTrue();
resultY.Success.Should().BeTrue();
resultX.BundleId.Should().NotBe(resultY.BundleId);
}
[Fact]
public async Task SchedulerJob_AppliesRetentionPolicy()
{
// Arrange: Create old bundle
var oldPeriodStart = DateTimeOffset.UtcNow.AddMonths(-36);
var oldBundle = CreateExpiredBundle("old-bundle", oldPeriodStart);
await _store.StoreBundleAsync(oldBundle);
// Verify old bundle exists
(await _store.ExistsAsync("old-bundle")).Should().BeTrue();
// Act: Apply retention
var deleted = await ApplyRetentionAsync(retentionMonths: 24);
// Assert
deleted.Should().BeGreaterThan(0);
(await _store.ExistsAsync("old-bundle")).Should().BeFalse();
}
#endregion
#region Helper Methods
private async Task<AttestationBundle> CreateBundleAsync(BundleCreationRequest request)
{
var attestations = await _aggregator
.AggregateAsync(new AggregationRequest(
request.PeriodStart,
request.PeriodEnd,
request.TenantId))
.ToListAsync();
// Sort for determinism
attestations = attestations.OrderBy(a => a.EntryId).ToList();
// Compute Merkle root (simplified)
var merkleRoot = ComputeMerkleRoot(attestations);
var bundle = new AttestationBundle
{
Metadata = new BundleMetadata
{
BundleId = $"sha256:{merkleRoot}",
CreatedAt = DateTimeOffset.UtcNow,
PeriodStart = request.PeriodStart,
PeriodEnd = request.PeriodEnd,
AttestationCount = attestations.Count,
TenantId = request.TenantId
},
Attestations = attestations,
MerkleTree = new MerkleTreeInfo
{
Root = $"sha256:{merkleRoot}",
LeafCount = attestations.Count
}
};
// Add org signature if requested
if (request.SignWithOrgKey && request.OrgKeyId != null)
{
var digest = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(merkleRoot));
var signature = await _signer.SignBundleAsync(digest, request.OrgKeyId);
bundle = bundle with
{
OrgSignature = signature,
Metadata = bundle.Metadata with { OrgKeyFingerprint = $"sha256:{request.OrgKeyId}" }
};
}
return bundle;
}
private async Task<bool> VerifyBundleAsync(AttestationBundle bundle)
{
// Verify Merkle root
var computedRoot = ComputeMerkleRoot(bundle.Attestations.ToList());
if (bundle.MerkleTree.Root != $"sha256:{computedRoot}")
{
return false;
}
// Verify org signature if present
if (bundle.OrgSignature != null)
{
var digest = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(computedRoot));
return await _signer.VerifyBundleAsync(digest, bundle.OrgSignature);
}
return true;
}
private async Task<RotationJobResult> ExecuteRotationJobAsync(
DateTimeOffset periodStart,
DateTimeOffset periodEnd,
string? tenantId = null)
{
try
{
var bundle = await CreateBundleAsync(new BundleCreationRequest(
periodStart, periodEnd,
TenantId: tenantId,
SignWithOrgKey: true,
OrgKeyId: "scheduler-key"));
await _store.StoreBundleAsync(bundle);
return new RotationJobResult
{
Success = true,
BundleId = bundle.Metadata.BundleId,
AttestationCount = bundle.Metadata.AttestationCount
};
}
catch (Exception ex)
{
return new RotationJobResult
{
Success = false,
Error = ex.Message
};
}
}
private async Task<int> ApplyRetentionAsync(int retentionMonths)
{
var cutoff = DateTimeOffset.UtcNow.AddMonths(-retentionMonths);
var deleted = 0;
var bundles = await _store.ListBundlesAsync(new BundleListRequest());
foreach (var bundle in bundles.Bundles)
{
if (bundle.CreatedAt < cutoff)
{
if (await _store.DeleteBundleAsync(bundle.BundleId))
{
deleted++;
}
}
}
return deleted;
}
private AttestationBundle CreateExpiredBundle(string bundleId, DateTimeOffset createdAt)
{
return new AttestationBundle
{
Metadata = new BundleMetadata
{
BundleId = bundleId,
CreatedAt = createdAt,
PeriodStart = createdAt.AddDays(-30),
PeriodEnd = createdAt,
AttestationCount = 0
},
Attestations = new List<BundledAttestation>(),
MerkleTree = new MerkleTreeInfo { Root = "sha256:empty", LeafCount = 0 }
};
}
private static string ComputeMerkleRoot(List<BundledAttestation> attestations)
{
if (attestations.Count == 0)
{
return "empty";
}
using var sha256 = System.Security.Cryptography.SHA256.Create();
var combined = string.Join("|", attestations.Select(a => a.EntryId));
var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static BundledAttestation CreateTestAttestation(string entryId, DateTimeOffset signedAt)
{
return new BundledAttestation
{
EntryId = entryId,
RekorUuid = $"rekor-{entryId}",
RekorLogIndex = Random.Shared.NextInt64(1000000),
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
PredicateType = "verdict.stella/v1",
SignedAt = signedAt,
SigningMode = "keyless",
SigningIdentity = new SigningIdentity
{
Issuer = "https://token.actions.githubusercontent.com",
Subject = "repo:org/repo:ref:refs/heads/main"
},
Envelope = new DsseEnvelopeData
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"payload-{entryId}")),
Signatures = new List<EnvelopeSignature>
{
new() { Sig = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"sig-{entryId}")) }
}
}
};
}
private sealed record RotationJobResult
{
public bool Success { get; init; }
public string BundleId { get; init; } = string.Empty;
public int AttestationCount { get; init; }
public string? Error { get; init; }
}
#endregion
}
/// <summary>
/// In-memory bundle store for integration testing.
/// </summary>
internal sealed class InMemoryBundleStore : IBundleStore
{
private readonly Dictionary<string, AttestationBundle> _bundles = new();
public Task StoreBundleAsync(
AttestationBundle bundle,
Abstractions.BundleStorageOptions? options = null,
CancellationToken cancellationToken = default)
{
_bundles[bundle.Metadata.BundleId] = bundle;
return Task.CompletedTask;
}
public Task<AttestationBundle?> GetBundleAsync(
string bundleId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_bundles.TryGetValue(bundleId, out var bundle) ? bundle : null);
}
public Task<bool> ExistsAsync(string bundleId, CancellationToken cancellationToken = default)
{
return Task.FromResult(_bundles.ContainsKey(bundleId));
}
public Task<bool> DeleteBundleAsync(string bundleId, CancellationToken cancellationToken = default)
{
return Task.FromResult(_bundles.Remove(bundleId));
}
public Task<BundleListResult> ListBundlesAsync(
BundleListRequest request,
CancellationToken cancellationToken = default)
{
var items = _bundles.Values
.Select(b => new BundleListItem(
b.Metadata.BundleId,
b.Metadata.PeriodStart,
b.Metadata.PeriodEnd,
b.Metadata.AttestationCount,
b.Metadata.CreatedAt,
b.OrgSignature != null))
.OrderByDescending(b => b.CreatedAt)
.ToList();
return Task.FromResult(new BundleListResult(items, null));
}
public Task ExportBundleAsync(
string bundleId,
Stream output,
Abstractions.BundleExportOptions? options = null,
CancellationToken cancellationToken = default)
{
if (_bundles.TryGetValue(bundleId, out var bundle))
{
var json = System.Text.Json.JsonSerializer.Serialize(bundle);
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
output.Write(bytes);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,540 @@
// -----------------------------------------------------------------------------
// KmsOrgKeySignerTests.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0021 - Unit tests: Org-key signing
// Description: Unit tests for KmsOrgKeySigner service
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Models;
using StellaOps.Attestor.Bundling.Signing;
namespace StellaOps.Attestor.Bundling.Tests;
public class KmsOrgKeySignerTests
{
private readonly Mock<IKmsProvider> _kmsProviderMock;
private readonly Mock<ILogger<KmsOrgKeySigner>> _loggerMock;
public KmsOrgKeySignerTests()
{
_kmsProviderMock = new Mock<IKmsProvider>();
_loggerMock = new Mock<ILogger<KmsOrgKeySigner>>();
}
#region SignBundleAsync Tests
[Fact]
public async Task SignBundleAsync_ValidKey_ReturnsSignature()
{
// Arrange
var keyId = "org-key-2025";
var bundleDigest = SHA256.HashData("test bundle content"u8.ToArray());
var expectedSignature = new byte[64];
RandomNumberGenerator.Fill(expectedSignature);
var keyInfo = CreateKeyInfo(keyId, isActive: true);
SetupKmsProvider(keyId, keyInfo, expectedSignature);
var signer = CreateSigner();
// Act
var result = await signer.SignBundleAsync(bundleDigest, keyId);
// Assert
result.Should().NotBeNull();
result.KeyId.Should().Be(keyId);
result.Algorithm.Should().Be("ECDSA_P256");
result.Signature.Should().Be(Convert.ToBase64String(expectedSignature));
result.SignedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task SignBundleAsync_KeyNotFound_ThrowsException()
{
// Arrange
var keyId = "nonexistent-key";
var bundleDigest = SHA256.HashData("test"u8.ToArray());
_kmsProviderMock
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
.ReturnsAsync((KmsKeyInfo?)null);
var signer = CreateSigner();
// Act & Assert
var act = () => signer.SignBundleAsync(bundleDigest, keyId);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage($"*'{keyId}'*not found*");
}
[Fact]
public async Task SignBundleAsync_InactiveKey_ThrowsException()
{
// Arrange
var keyId = "inactive-key";
var bundleDigest = SHA256.HashData("test"u8.ToArray());
var keyInfo = CreateKeyInfo(keyId, isActive: false);
_kmsProviderMock
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
.ReturnsAsync(keyInfo);
var signer = CreateSigner();
// Act & Assert
var act = () => signer.SignBundleAsync(bundleDigest, keyId);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage($"*'{keyId}'*not active*");
}
[Fact]
public async Task SignBundleAsync_ExpiredKey_ThrowsException()
{
// Arrange
var keyId = "expired-key";
var bundleDigest = SHA256.HashData("test"u8.ToArray());
var keyInfo = new KmsKeyInfo(
keyId,
"ECDSA_P256",
"fingerprint",
DateTimeOffset.UtcNow.AddYears(-2),
DateTimeOffset.UtcNow.AddDays(-1), // Expired yesterday
true);
_kmsProviderMock
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
.ReturnsAsync(keyInfo);
var signer = CreateSigner();
// Act & Assert
var act = () => signer.SignBundleAsync(bundleDigest, keyId);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage($"*'{keyId}'*expired*");
}
[Fact]
public async Task SignBundleAsync_WithCertificateChain_IncludesChainInSignature()
{
// Arrange
var keyId = "org-key-with-cert";
var bundleDigest = SHA256.HashData("test"u8.ToArray());
var signature = new byte[64];
var certChain = new List<string>
{
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----",
"-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"
};
var keyInfo = CreateKeyInfo(keyId, isActive: true);
SetupKmsProvider(keyId, keyInfo, signature, certChain);
var signer = CreateSigner();
// Act
var result = await signer.SignBundleAsync(bundleDigest, keyId);
// Assert
result.CertificateChain.Should().NotBeNull();
result.CertificateChain.Should().HaveCount(2);
}
#endregion
#region VerifyBundleAsync Tests
[Fact]
public async Task VerifyBundleAsync_ValidSignature_ReturnsTrue()
{
// Arrange
var keyId = "org-key-2025";
var bundleDigest = SHA256.HashData("test bundle content"u8.ToArray());
var signatureBytes = new byte[64];
RandomNumberGenerator.Fill(signatureBytes);
var signature = new OrgSignature
{
KeyId = keyId,
Algorithm = "ECDSA_P256",
Signature = Convert.ToBase64String(signatureBytes),
SignedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CertificateChain = null
};
_kmsProviderMock
.Setup(x => x.VerifyAsync(
keyId,
bundleDigest,
signatureBytes,
"ECDSA_P256",
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var signer = CreateSigner();
// Act
var result = await signer.VerifyBundleAsync(bundleDigest, signature);
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task VerifyBundleAsync_InvalidSignature_ReturnsFalse()
{
// Arrange
var keyId = "org-key-2025";
var bundleDigest = SHA256.HashData("test"u8.ToArray());
var signatureBytes = new byte[64];
var signature = new OrgSignature
{
KeyId = keyId,
Algorithm = "ECDSA_P256",
Signature = Convert.ToBase64String(signatureBytes),
SignedAt = DateTimeOffset.UtcNow,
CertificateChain = null
};
_kmsProviderMock
.Setup(x => x.VerifyAsync(
keyId,
bundleDigest,
signatureBytes,
"ECDSA_P256",
It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var signer = CreateSigner();
// Act
var result = await signer.VerifyBundleAsync(bundleDigest, signature);
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task VerifyBundleAsync_KmsThrowsException_ReturnsFalse()
{
// Arrange
var keyId = "org-key-2025";
var bundleDigest = SHA256.HashData("test"u8.ToArray());
var signatureBytes = new byte[64];
var signature = new OrgSignature
{
KeyId = keyId,
Algorithm = "ECDSA_P256",
Signature = Convert.ToBase64String(signatureBytes),
SignedAt = DateTimeOffset.UtcNow,
CertificateChain = null
};
_kmsProviderMock
.Setup(x => x.VerifyAsync(
It.IsAny<string>(),
It.IsAny<byte[]>(),
It.IsAny<byte[]>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("KMS unavailable"));
var signer = CreateSigner();
// Act
var result = await signer.VerifyBundleAsync(bundleDigest, signature);
// Assert
result.Should().BeFalse();
}
#endregion
#region GetActiveKeyIdAsync Tests
[Fact]
public async Task GetActiveKeyIdAsync_ConfiguredActiveKey_ReturnsConfiguredKey()
{
// Arrange
var options = Options.Create(new OrgSigningOptions
{
ActiveKeyId = "configured-active-key"
});
var signer = new KmsOrgKeySigner(
_kmsProviderMock.Object,
_loggerMock.Object,
options);
// Act
var result = await signer.GetActiveKeyIdAsync();
// Assert
result.Should().Be("configured-active-key");
}
[Fact]
public async Task GetActiveKeyIdAsync_NoConfiguredKey_ReturnsNewestActiveKey()
{
// Arrange
var keys = new List<KmsKeyInfo>
{
new("key-2024", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-1), null, true),
new("key-2025", "ECDSA_P256", "fp2", DateTimeOffset.UtcNow.AddMonths(-1), null, true),
new("key-2023", "ECDSA_P256", "fp3", DateTimeOffset.UtcNow.AddYears(-2), null, false) // Inactive
};
_kmsProviderMock
.Setup(x => x.ListKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(keys);
var signer = CreateSigner();
// Act
var result = await signer.GetActiveKeyIdAsync();
// Assert
result.Should().Be("key-2025"); // Newest active key
}
[Fact]
public async Task GetActiveKeyIdAsync_NoActiveKeys_ThrowsException()
{
// Arrange
var keys = new List<KmsKeyInfo>
{
new("key-inactive", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-1), null, false)
};
_kmsProviderMock
.Setup(x => x.ListKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(keys);
var signer = CreateSigner();
// Act & Assert
var act = () => signer.GetActiveKeyIdAsync();
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*No active signing key*");
}
[Fact]
public async Task GetActiveKeyIdAsync_ExcludesExpiredKeys()
{
// Arrange
var keys = new List<KmsKeyInfo>
{
new("key-expired", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-2), DateTimeOffset.UtcNow.AddDays(-1), true),
new("key-valid", "ECDSA_P256", "fp2", DateTimeOffset.UtcNow.AddMonths(-6), null, true)
};
_kmsProviderMock
.Setup(x => x.ListKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(keys);
var signer = CreateSigner();
// Act
var result = await signer.GetActiveKeyIdAsync();
// Assert
result.Should().Be("key-valid");
}
#endregion
#region ListKeysAsync Tests
[Fact]
public async Task ListKeysAsync_ReturnsAllKeysFromKms()
{
// Arrange
var keys = new List<KmsKeyInfo>
{
new("key-1", "ECDSA_P256", "fp1", DateTimeOffset.UtcNow.AddYears(-1), null, true),
new("key-2", "Ed25519", "fp2", DateTimeOffset.UtcNow.AddMonths(-6), DateTimeOffset.UtcNow.AddMonths(6), true)
};
_kmsProviderMock
.Setup(x => x.ListKeysAsync("stellaops/org-signing/", It.IsAny<CancellationToken>()))
.ReturnsAsync(keys);
var signer = CreateSigner();
// Act
var result = await signer.ListKeysAsync();
// Assert
result.Should().HaveCount(2);
result.Should().Contain(k => k.KeyId == "key-1" && k.Algorithm == "ECDSA_P256");
result.Should().Contain(k => k.KeyId == "key-2" && k.Algorithm == "Ed25519");
}
#endregion
#region LocalOrgKeySigner Tests
[Fact]
public async Task LocalOrgKeySigner_SignAndVerify_Roundtrip()
{
// Arrange
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
var signer = new LocalOrgKeySigner(logger.Object);
signer.AddKey("test-key-1", isActive: true);
var bundleDigest = SHA256.HashData("test bundle content"u8.ToArray());
// Act
var signature = await signer.SignBundleAsync(bundleDigest, "test-key-1");
var isValid = await signer.VerifyBundleAsync(bundleDigest, signature);
// Assert
isValid.Should().BeTrue();
signature.KeyId.Should().Be("test-key-1");
signature.Algorithm.Should().Be("ECDSA_P256");
}
[Fact]
public async Task LocalOrgKeySigner_VerifyWithWrongDigest_ReturnsFalse()
{
// Arrange
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
var signer = new LocalOrgKeySigner(logger.Object);
signer.AddKey("test-key-1", isActive: true);
var originalDigest = SHA256.HashData("original content"u8.ToArray());
var tamperedDigest = SHA256.HashData("tampered content"u8.ToArray());
// Act
var signature = await signer.SignBundleAsync(originalDigest, "test-key-1");
var isValid = await signer.VerifyBundleAsync(tamperedDigest, signature);
// Assert
isValid.Should().BeFalse();
}
[Fact]
public async Task LocalOrgKeySigner_VerifyWithUnknownKey_ReturnsFalse()
{
// Arrange
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
var signer = new LocalOrgKeySigner(logger.Object);
signer.AddKey("test-key-1", isActive: true);
var bundleDigest = SHA256.HashData("test"u8.ToArray());
var signature = await signer.SignBundleAsync(bundleDigest, "test-key-1");
// Modify signature to reference unknown key
var fakeSignature = signature with { KeyId = "unknown-key" };
// Act
var isValid = await signer.VerifyBundleAsync(bundleDigest, fakeSignature);
// Assert
isValid.Should().BeFalse();
}
[Fact]
public async Task LocalOrgKeySigner_GetActiveKeyId_ReturnsActiveKey()
{
// Arrange
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
var signer = new LocalOrgKeySigner(logger.Object);
signer.AddKey("key-1", isActive: false);
signer.AddKey("key-2", isActive: true);
// Act
var activeKeyId = await signer.GetActiveKeyIdAsync();
// Assert
activeKeyId.Should().Be("key-2");
}
[Fact]
public async Task LocalOrgKeySigner_NoActiveKey_ThrowsException()
{
// Arrange
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
var signer = new LocalOrgKeySigner(logger.Object);
// Don't add any keys
// Act & Assert
var act = () => signer.GetActiveKeyIdAsync();
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*No active signing key*");
}
[Fact]
public async Task LocalOrgKeySigner_ListKeys_ReturnsAllKeys()
{
// Arrange
var logger = new Mock<ILogger<LocalOrgKeySigner>>();
var signer = new LocalOrgKeySigner(logger.Object);
signer.AddKey("key-1", isActive: true);
signer.AddKey("key-2", isActive: false);
// Act
var keys = await signer.ListKeysAsync();
// Assert
keys.Should().HaveCount(2);
keys.Should().Contain(k => k.KeyId == "key-1" && k.IsActive);
keys.Should().Contain(k => k.KeyId == "key-2" && !k.IsActive);
}
#endregion
#region Helper Methods
private KmsOrgKeySigner CreateSigner(OrgSigningOptions? options = null)
{
return new KmsOrgKeySigner(
_kmsProviderMock.Object,
_loggerMock.Object,
Options.Create(options ?? new OrgSigningOptions()));
}
private static KmsKeyInfo CreateKeyInfo(string keyId, bool isActive, DateTimeOffset? validUntil = null)
{
return new KmsKeyInfo(
keyId,
"ECDSA_P256",
$"fingerprint-{keyId}",
DateTimeOffset.UtcNow.AddMonths(-1),
validUntil,
isActive);
}
private void SetupKmsProvider(
string keyId,
KmsKeyInfo keyInfo,
byte[] signature,
IReadOnlyList<string>? certChain = null)
{
_kmsProviderMock
.Setup(x => x.GetKeyInfoAsync(keyId, It.IsAny<CancellationToken>()))
.ReturnsAsync(keyInfo);
_kmsProviderMock
.Setup(x => x.SignAsync(
keyId,
It.IsAny<byte[]>(),
keyInfo.Algorithm,
It.IsAny<CancellationToken>()))
.ReturnsAsync(signature);
_kmsProviderMock
.Setup(x => x.GetCertificateChainAsync(keyId, It.IsAny<CancellationToken>()))
.ReturnsAsync(certChain);
}
#endregion
}

View File

@@ -0,0 +1,303 @@
// -----------------------------------------------------------------------------
// OrgKeySignerTests.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0021 - Unit tests: Org-key signing
// Description: Unit tests for organization key signing with sign/verify roundtrip
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Models;
using Xunit;
namespace StellaOps.Attestor.Bundling.Tests;
public class OrgKeySignerTests
{
private readonly TestOrgKeySigner _signer;
private readonly string _testKeyId = "test-org-key-2025";
public OrgKeySignerTests()
{
_signer = new TestOrgKeySigner();
}
#region Sign/Verify Roundtrip Tests
[Fact]
public async Task SignAndVerify_ValidBundle_Succeeds()
{
// Arrange
var bundleDigest = SHA256.HashData("test-bundle-content"u8.ToArray());
// Act
var signature = await _signer.SignBundleAsync(bundleDigest, _testKeyId);
// Assert
signature.Should().NotBeNull();
signature.KeyId.Should().Be(_testKeyId);
signature.Algorithm.Should().Be("ECDSA_P256");
signature.Signature.Should().NotBeEmpty();
signature.SignedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Verify roundtrip
var isValid = await _signer.VerifyBundleAsync(bundleDigest, signature);
isValid.Should().BeTrue();
}
[Fact]
public async Task SignAndVerify_DifferentContent_Fails()
{
// Arrange
var originalDigest = SHA256.HashData("original-content"u8.ToArray());
var tamperedDigest = SHA256.HashData("tampered-content"u8.ToArray());
// Act
var signature = await _signer.SignBundleAsync(originalDigest, _testKeyId);
var isValid = await _signer.VerifyBundleAsync(tamperedDigest, signature);
// Assert
isValid.Should().BeFalse();
}
[Fact]
public async Task SignAndVerify_SameContentDifferentCalls_BothValid()
{
// Arrange
var content = "consistent-bundle-content"u8.ToArray();
var digest1 = SHA256.HashData(content);
var digest2 = SHA256.HashData(content);
// Act
var signature1 = await _signer.SignBundleAsync(digest1, _testKeyId);
var signature2 = await _signer.SignBundleAsync(digest2, _testKeyId);
// Assert - Both signatures should be valid for the same content
(await _signer.VerifyBundleAsync(digest1, signature1)).Should().BeTrue();
(await _signer.VerifyBundleAsync(digest2, signature2)).Should().BeTrue();
// Cross-verify: signature1 should verify against digest2 (same content)
(await _signer.VerifyBundleAsync(digest2, signature1)).Should().BeTrue();
}
#endregion
#region Certificate Chain Tests
[Fact]
public async Task Sign_IncludesCertificateChain()
{
// Arrange
var bundleDigest = SHA256.HashData("bundle-with-chain"u8.ToArray());
// Act
var signature = await _signer.SignBundleAsync(bundleDigest, _testKeyId);
// Assert
signature.CertificateChain.Should().NotBeNull();
signature.CertificateChain.Should().NotBeEmpty();
signature.CertificateChain!.All(c => c.StartsWith("-----BEGIN CERTIFICATE-----")).Should().BeTrue();
}
#endregion
#region Key ID Tests
[Fact]
public async Task Sign_WithDifferentKeyIds_ProducesDifferentSignatures()
{
// Arrange
var bundleDigest = SHA256.HashData("test-content"u8.ToArray());
var keyId1 = "org-key-2024";
var keyId2 = "org-key-2025";
// Act
var signature1 = await _signer.SignBundleAsync(bundleDigest, keyId1);
var signature2 = await _signer.SignBundleAsync(bundleDigest, keyId2);
// Assert
signature1.KeyId.Should().Be(keyId1);
signature2.KeyId.Should().Be(keyId2);
signature1.Signature.Should().NotBe(signature2.Signature);
}
[Fact]
public async Task Verify_WithWrongKeyId_Fails()
{
// Arrange
var bundleDigest = SHA256.HashData("test-content"u8.ToArray());
var signatureWithKey1 = await _signer.SignBundleAsync(bundleDigest, "key-1");
// Modify the key ID in the signature (simulating wrong key)
var tamperedSignature = signatureWithKey1 with { KeyId = "wrong-key" };
// Act
var isValid = await _signer.VerifyBundleAsync(bundleDigest, tamperedSignature);
// Assert
isValid.Should().BeFalse();
}
#endregion
#region Empty/Null Input Tests
[Fact]
public async Task Sign_EmptyDigest_StillSigns()
{
// Arrange
var emptyDigest = SHA256.HashData(Array.Empty<byte>());
// Act
var signature = await _signer.SignBundleAsync(emptyDigest, _testKeyId);
// Assert
signature.Should().NotBeNull();
signature.Signature.Should().NotBeEmpty();
// Verify works
(await _signer.VerifyBundleAsync(emptyDigest, signature)).Should().BeTrue();
}
#endregion
#region Algorithm Tests
[Theory]
[InlineData("ECDSA_P256")]
[InlineData("Ed25519")]
[InlineData("RSA_PSS_SHA256")]
public async Task Sign_SupportsMultipleAlgorithms(string algorithm)
{
// Arrange
var signer = new TestOrgKeySigner(algorithm);
var bundleDigest = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"test-{algorithm}"));
// Act
var signature = await signer.SignBundleAsync(bundleDigest, _testKeyId);
// Assert
signature.Algorithm.Should().Be(algorithm);
(await signer.VerifyBundleAsync(bundleDigest, signature)).Should().BeTrue();
}
#endregion
#region Timestamp Tests
[Fact]
public async Task Sign_IncludesAccurateTimestamp()
{
// Arrange
var beforeSign = DateTimeOffset.UtcNow;
var bundleDigest = SHA256.HashData("timestamp-test"u8.ToArray());
// Act
var signature = await _signer.SignBundleAsync(bundleDigest, _testKeyId);
var afterSign = DateTimeOffset.UtcNow;
// Assert
signature.SignedAt.Should().BeOnOrAfter(beforeSign);
signature.SignedAt.Should().BeOnOrBefore(afterSign);
}
#endregion
}
/// <summary>
/// Test implementation of IOrgKeySigner for unit testing.
/// Uses in-memory keys for sign/verify operations.
/// </summary>
internal sealed class TestOrgKeySigner : IOrgKeySigner
{
private readonly Dictionary<string, ECDsa> _keys = new();
private readonly string _algorithm;
public TestOrgKeySigner(string algorithm = "ECDSA_P256")
{
_algorithm = algorithm;
}
public Task<OrgSignature> SignBundleAsync(
byte[] bundleDigest,
string keyId,
CancellationToken cancellationToken = default)
{
var key = GetOrCreateKey(keyId);
var signature = key.SignData(bundleDigest, HashAlgorithmName.SHA256);
return Task.FromResult(new OrgSignature
{
KeyId = keyId,
Algorithm = _algorithm,
Signature = Convert.ToBase64String(signature),
SignedAt = DateTimeOffset.UtcNow,
CertificateChain = GenerateMockCertificateChain()
});
}
public Task<bool> VerifyBundleAsync(
byte[] bundleDigest,
OrgSignature signature,
CancellationToken cancellationToken = default)
{
if (!_keys.TryGetValue(signature.KeyId, out var key))
{
return Task.FromResult(false);
}
try
{
var signatureBytes = Convert.FromBase64String(signature.Signature);
var isValid = key.VerifyData(bundleDigest, signatureBytes, HashAlgorithmName.SHA256);
return Task.FromResult(isValid);
}
catch
{
return Task.FromResult(false);
}
}
public Task<string> GetActiveKeyIdAsync(CancellationToken cancellationToken = default)
{
var activeKey = _keys.Keys.FirstOrDefault();
if (activeKey == null)
{
throw new InvalidOperationException("No active signing key.");
}
return Task.FromResult(activeKey);
}
public Task<IReadOnlyList<OrgKeyInfo>> ListKeysAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<OrgKeyInfo>>(
_keys.Keys.Select(keyId => new OrgKeyInfo(
keyId,
_algorithm,
$"fingerprint-{keyId}",
DateTimeOffset.UtcNow.AddMonths(-1),
null,
true)).ToList());
}
private ECDsa GetOrCreateKey(string keyId)
{
if (!_keys.TryGetValue(keyId, out var key))
{
key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
_keys[keyId] = key;
}
return key;
}
private static IReadOnlyList<string> GenerateMockCertificateChain()
{
// Return mock PEM certificates for testing
return new[]
{
"-----BEGIN CERTIFICATE-----\nMIIBkjCB/AIJAKHBfpegPjEFMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl\nc3QtY2EwHhcNMjUwMTAxMDAwMDAwWhcNMjYwMTAxMDAwMDAwWjARMQ8wDQYDVQQD\nDAZ0ZXN0LWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtest\n-----END CERTIFICATE-----"
};
}
}

View File

@@ -0,0 +1,544 @@
// -----------------------------------------------------------------------------
// RetentionPolicyEnforcerTests.cs
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
// Task: 0022 - Unit tests: Retention policy
// Description: Unit tests for RetentionPolicyEnforcer service
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Configuration;
using StellaOps.Attestor.Bundling.Services;
namespace StellaOps.Attestor.Bundling.Tests;
public class RetentionPolicyEnforcerTests
{
private readonly Mock<IBundleStore> _storeMock;
private readonly Mock<IBundleArchiver> _archiverMock;
private readonly Mock<IBundleExpiryNotifier> _notifierMock;
private readonly Mock<ILogger<RetentionPolicyEnforcer>> _loggerMock;
public RetentionPolicyEnforcerTests()
{
_storeMock = new Mock<IBundleStore>();
_archiverMock = new Mock<IBundleArchiver>();
_notifierMock = new Mock<IBundleExpiryNotifier>();
_loggerMock = new Mock<ILogger<RetentionPolicyEnforcer>>();
}
#region CalculateExpiryDate Tests
[Fact]
public void CalculateExpiryDate_DefaultSettings_ReturnsCreatedPlusDefaultMonths()
{
// Arrange
var options = CreateOptions(new BundleRetentionOptions { DefaultMonths = 24 });
var enforcer = CreateEnforcer(options);
var createdAt = new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero);
// Act
var expiryDate = enforcer.CalculateExpiryDate(null, createdAt);
// Assert
expiryDate.Should().Be(new DateTimeOffset(2026, 6, 15, 10, 0, 0, TimeSpan.Zero));
}
[Fact]
public void CalculateExpiryDate_WithTenantOverride_UsesTenantSpecificRetention()
{
// Arrange
var retentionOptions = new BundleRetentionOptions
{
DefaultMonths = 24,
TenantOverrides = new Dictionary<string, int>
{
["tenant-gov"] = 84, // 7 years
["tenant-finance"] = 120 // 10 years
}
};
var options = CreateOptions(retentionOptions);
var enforcer = CreateEnforcer(options);
var createdAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Act
var govExpiry = enforcer.CalculateExpiryDate("tenant-gov", createdAt);
var financeExpiry = enforcer.CalculateExpiryDate("tenant-finance", createdAt);
var defaultExpiry = enforcer.CalculateExpiryDate("other-tenant", createdAt);
// Assert
govExpiry.Should().Be(new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)); // +84 months
financeExpiry.Should().Be(new DateTimeOffset(2034, 1, 1, 0, 0, 0, TimeSpan.Zero)); // +120 months
defaultExpiry.Should().Be(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); // +24 months
}
[Fact]
public void CalculateExpiryDate_TenantOverrideBelowMinimum_UsesMinimum()
{
// Arrange
var retentionOptions = new BundleRetentionOptions
{
DefaultMonths = 24,
MinimumMonths = 6,
TenantOverrides = new Dictionary<string, int>
{
["short-tenant"] = 3 // Below minimum
}
};
var options = CreateOptions(retentionOptions);
var enforcer = CreateEnforcer(options);
var createdAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Act
var expiry = enforcer.CalculateExpiryDate("short-tenant", createdAt);
// Assert - Should use minimum of 6 months, not 3
expiry.Should().Be(new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public void CalculateExpiryDate_TenantOverrideAboveMaximum_UsesMaximum()
{
// Arrange
var retentionOptions = new BundleRetentionOptions
{
DefaultMonths = 24,
MaximumMonths = 120, // 10 years max
TenantOverrides = new Dictionary<string, int>
{
["forever-tenant"] = 240 // 20 years - above maximum
}
};
var options = CreateOptions(retentionOptions);
var enforcer = CreateEnforcer(options);
var createdAt = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Act
var expiry = enforcer.CalculateExpiryDate("forever-tenant", createdAt);
// Assert - Should cap at maximum of 120 months
expiry.Should().Be(new DateTimeOffset(2034, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public void CalculateExpiryDate_WithBundleListItem_UsesCreatedAtFromItem()
{
// Arrange
var options = CreateOptions(new BundleRetentionOptions { DefaultMonths = 12 });
var enforcer = CreateEnforcer(options);
var bundle = CreateBundleListItem("bundle-1", new DateTimeOffset(2024, 3, 15, 0, 0, 0, TimeSpan.Zero));
// Act
var expiry = enforcer.CalculateExpiryDate(bundle);
// Assert
expiry.Should().Be(new DateTimeOffset(2025, 3, 15, 0, 0, 0, TimeSpan.Zero));
}
#endregion
#region EnforceAsync Tests
[Fact]
public async Task EnforceAsync_WhenDisabled_ReturnsEarlyWithZeroCounts()
{
// Arrange
var options = CreateOptions(new BundleRetentionOptions { Enabled = false });
var enforcer = CreateEnforcer(options);
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.Success.Should().BeTrue();
result.BundlesEvaluated.Should().Be(0);
result.BundlesDeleted.Should().Be(0);
result.BundlesArchived.Should().Be(0);
result.BundlesMarkedExpired.Should().Be(0);
_storeMock.Verify(x => x.ListBundlesAsync(
It.IsAny<BundleListRequest>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task EnforceAsync_WithExpiredBundles_DeletesWhenActionIsDelete()
{
// Arrange
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36)); // 3 years old
var activeBundles = CreateBundleListItem("active-1", DateTimeOffset.UtcNow.AddMonths(-6)); // 6 months old
SetupBundleStore(expiredBundle, activeBundles);
_storeMock
.Setup(x => x.DeleteBundleAsync("expired-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 0, // No grace period for test
ExpiryAction = RetentionAction.Delete
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.Success.Should().BeTrue();
result.BundlesEvaluated.Should().Be(2);
result.BundlesDeleted.Should().Be(1);
_storeMock.Verify(x => x.DeleteBundleAsync("expired-1", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EnforceAsync_WithExpiredBundles_ArchivesWhenActionIsArchive()
{
// Arrange
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
SetupBundleStore(expiredBundle);
_archiverMock
.Setup(x => x.ArchiveAsync("expired-1", "glacier", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 0,
ExpiryAction = RetentionAction.Archive,
ArchiveStorageTier = "glacier"
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions), _archiverMock.Object);
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.Success.Should().BeTrue();
result.BundlesArchived.Should().Be(1);
_archiverMock.Verify(x => x.ArchiveAsync("expired-1", "glacier", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EnforceAsync_WithExpiredBundles_MarksOnlyWhenActionIsMarkOnly()
{
// Arrange
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
SetupBundleStore(expiredBundle);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 0,
ExpiryAction = RetentionAction.MarkOnly
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.Success.Should().BeTrue();
result.BundlesMarkedExpired.Should().Be(1);
result.BundlesDeleted.Should().Be(0);
result.BundlesArchived.Should().Be(0);
// Verify no delete or archive was called
_storeMock.Verify(x => x.DeleteBundleAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task EnforceAsync_BundleInGracePeriod_MarksExpiredButDoesNotDelete()
{
// Arrange
// Bundle expired 15 days ago (within 30-day grace period)
var gracePeriodBundle = CreateBundleListItem(
"grace-1",
DateTimeOffset.UtcNow.AddMonths(-24).AddDays(-15));
SetupBundleStore(gracePeriodBundle);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 30,
ExpiryAction = RetentionAction.Delete
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.BundlesMarkedExpired.Should().Be(1);
result.BundlesDeleted.Should().Be(0);
_storeMock.Verify(x => x.DeleteBundleAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task EnforceAsync_BundlePastGracePeriod_DeletesBundle()
{
// Arrange
// Bundle expired 45 days ago (past 30-day grace period)
var pastGraceBundle = CreateBundleListItem(
"past-grace-1",
DateTimeOffset.UtcNow.AddMonths(-24).AddDays(-45));
SetupBundleStore(pastGraceBundle);
_storeMock
.Setup(x => x.DeleteBundleAsync("past-grace-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 30,
ExpiryAction = RetentionAction.Delete
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.BundlesDeleted.Should().Be(1);
_storeMock.Verify(x => x.DeleteBundleAsync("past-grace-1", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EnforceAsync_BundleApproachingExpiry_SendsNotification()
{
// Arrange
// Bundle will expire in 15 days (within 30-day notification window)
var approachingBundle = CreateBundleListItem(
"approaching-1",
DateTimeOffset.UtcNow.AddMonths(-24).AddDays(15));
SetupBundleStore(approachingBundle);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
NotifyBeforeExpiry = true,
NotifyDaysBeforeExpiry = 30
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions), notifier: _notifierMock.Object);
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.BundlesApproachingExpiry.Should().Be(1);
_notifierMock.Verify(x => x.NotifyAsync(
It.Is<IReadOnlyList<BundleExpiryNotification>>(n =>
n.Count == 1 &&
n[0].BundleId == "approaching-1"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EnforceAsync_NoArchiverConfigured_ReturnsFailureForArchiveAction()
{
// Arrange
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
SetupBundleStore(expiredBundle);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 0,
ExpiryAction = RetentionAction.Archive
};
// Create enforcer WITHOUT archiver
var enforcer = CreateEnforcer(CreateOptions(retentionOptions), archiver: null);
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.Success.Should().BeFalse();
result.Failures.Should().HaveCount(1);
result.Failures[0].BundleId.Should().Be("expired-1");
result.Failures[0].Reason.Should().Be("Archive unavailable");
}
[Fact]
public async Task EnforceAsync_DeleteFails_RecordsFailure()
{
// Arrange
var expiredBundle = CreateBundleListItem("expired-1", DateTimeOffset.UtcNow.AddMonths(-36));
SetupBundleStore(expiredBundle);
_storeMock
.Setup(x => x.DeleteBundleAsync("expired-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(false); // Simulate delete failure
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 0,
ExpiryAction = RetentionAction.Delete
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
// Act
var result = await enforcer.EnforceAsync();
// Assert
result.Success.Should().BeFalse();
result.BundlesDeleted.Should().Be(0);
result.Failures.Should().HaveCount(1);
result.Failures[0].BundleId.Should().Be("expired-1");
result.Failures[0].Reason.Should().Be("Delete failed");
}
[Fact]
public async Task EnforceAsync_RespectsMaxBundlesPerRun_StopsFetchingAfterLimit()
{
// Arrange
// First batch returns 5 bundles with cursor for more
var batch1 = Enumerable.Range(1, 5)
.Select(i => CreateBundleListItem($"bundle-{i}", DateTimeOffset.UtcNow.AddMonths(-36)))
.ToList();
// Second batch would return 5 more, but should not be fetched
var batch2 = Enumerable.Range(6, 5)
.Select(i => CreateBundleListItem($"bundle-{i}", DateTimeOffset.UtcNow.AddMonths(-36)))
.ToList();
var callCount = 0;
_storeMock
.Setup(x => x.ListBundlesAsync(It.IsAny<BundleListRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
return callCount == 1
? new BundleListResult(batch1, "cursor2") // Has more pages
: new BundleListResult(batch2, null); // Last page
});
_storeMock
.Setup(x => x.DeleteBundleAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var retentionOptions = new BundleRetentionOptions
{
Enabled = true,
DefaultMonths = 24,
GracePeriodDays = 0,
ExpiryAction = RetentionAction.Delete,
MaxBundlesPerRun = 5
};
var enforcer = CreateEnforcer(CreateOptions(retentionOptions));
// Act
var result = await enforcer.EnforceAsync();
// Assert
// Should evaluate first batch (5) and stop before fetching second batch
result.BundlesEvaluated.Should().Be(5);
callCount.Should().Be(1, "should only fetch one batch when limit is reached");
}
#endregion
#region GetApproachingExpiryAsync Tests
[Fact]
public async Task GetApproachingExpiryAsync_ReturnsBundlesWithinCutoff()
{
// Arrange
var expiresIn10Days = CreateBundleListItem("expires-10", DateTimeOffset.UtcNow.AddMonths(-24).AddDays(10));
var expiresIn45Days = CreateBundleListItem("expires-45", DateTimeOffset.UtcNow.AddMonths(-24).AddDays(45));
var alreadyExpired = CreateBundleListItem("expired", DateTimeOffset.UtcNow.AddMonths(-25));
SetupBundleStore(expiresIn10Days, expiresIn45Days, alreadyExpired);
var options = CreateOptions(new BundleRetentionOptions { DefaultMonths = 24 });
var enforcer = CreateEnforcer(options);
// Act
var notifications = await enforcer.GetApproachingExpiryAsync(daysBeforeExpiry: 30);
// Assert
notifications.Should().HaveCount(1);
notifications[0].BundleId.Should().Be("expires-10");
notifications[0].DaysUntilExpiry.Should().BeCloseTo(10, 1); // Allow 1 day tolerance
}
#endregion
#region Helper Methods
private IOptions<BundlingOptions> CreateOptions(BundleRetentionOptions retentionOptions)
{
return Options.Create(new BundlingOptions
{
Retention = retentionOptions
});
}
private RetentionPolicyEnforcer CreateEnforcer(
IOptions<BundlingOptions> options,
IBundleArchiver? archiver = null,
IBundleExpiryNotifier? notifier = null)
{
return new RetentionPolicyEnforcer(
_storeMock.Object,
options,
_loggerMock.Object,
archiver,
notifier);
}
private void SetupBundleStore(params BundleListItem[] bundles)
{
_storeMock
.Setup(x => x.ListBundlesAsync(It.IsAny<BundleListRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BundleListResult(bundles.ToList(), null));
}
private static BundleListItem CreateBundleListItem(string bundleId, DateTimeOffset createdAt)
{
return new BundleListItem(
BundleId: bundleId,
PeriodStart: createdAt.AddDays(-30),
PeriodEnd: createdAt,
AttestationCount: 100,
CreatedAt: createdAt,
HasOrgSignature: false);
}
#endregion
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Attestor.Bundling.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,387 @@
// -----------------------------------------------------------------------------
// FileSystemRootStoreTests.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0023 - Unit tests for FileSystemRootStore
// Description: Unit tests for file-based root certificate store
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Offline.Abstractions;
using StellaOps.Attestor.Offline.Services;
namespace StellaOps.Attestor.Offline.Tests;
public class FileSystemRootStoreTests : IDisposable
{
private readonly Mock<ILogger<FileSystemRootStore>> _loggerMock;
private readonly string _testRootPath;
public FileSystemRootStoreTests()
{
_loggerMock = new Mock<ILogger<FileSystemRootStore>>();
_testRootPath = Path.Combine(Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testRootPath);
}
public void Dispose()
{
if (Directory.Exists(_testRootPath))
{
Directory.Delete(_testRootPath, recursive: true);
}
}
[Fact]
public async Task GetFulcioRootsAsync_WithNoCertificates_ReturnsEmptyCollection()
{
// Arrange
var options = CreateOptions();
var store = CreateStore(options);
// Act
var roots = await store.GetFulcioRootsAsync();
// Assert
roots.Should().BeEmpty();
}
[Fact]
public async Task GetFulcioRootsAsync_WithPemFile_ReturnsCertificates()
{
// Arrange
var cert = CreateTestCertificate("CN=Test Fulcio Root");
var pemPath = Path.Combine(_testRootPath, "fulcio.pem");
await WritePemFileAsync(pemPath, cert);
var options = CreateOptions(fulcioPath: pemPath);
var store = CreateStore(options);
// Act
var roots = await store.GetFulcioRootsAsync();
// Assert
roots.Should().HaveCount(1);
roots[0].Subject.Should().Be("CN=Test Fulcio Root");
}
[Fact]
public async Task GetFulcioRootsAsync_WithDirectory_LoadsAllPemFiles()
{
// Arrange
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
Directory.CreateDirectory(fulcioDir);
var cert1 = CreateTestCertificate("CN=Root 1");
var cert2 = CreateTestCertificate("CN=Root 2");
await WritePemFileAsync(Path.Combine(fulcioDir, "root1.pem"), cert1);
await WritePemFileAsync(Path.Combine(fulcioDir, "root2.pem"), cert2);
var options = CreateOptions(fulcioPath: fulcioDir);
var store = CreateStore(options);
// Act
var roots = await store.GetFulcioRootsAsync();
// Assert
roots.Should().HaveCount(2);
}
[Fact]
public async Task GetFulcioRootsAsync_CachesCertificates_OnSecondCall()
{
// Arrange
var cert = CreateTestCertificate("CN=Cached Root");
var pemPath = Path.Combine(_testRootPath, "cached.pem");
await WritePemFileAsync(pemPath, cert);
var options = CreateOptions(fulcioPath: pemPath);
var store = CreateStore(options);
// Act
var roots1 = await store.GetFulcioRootsAsync();
var roots2 = await store.GetFulcioRootsAsync();
// Assert - same collection instance (cached)
roots1.Should().HaveCount(1);
roots2.Should().HaveCount(1);
// Both calls should return same data
roots1[0].Subject.Should().Be(roots2[0].Subject);
}
[Fact]
public async Task ImportRootsAsync_WithValidPem_SavesCertificates()
{
// Arrange
var cert = CreateTestCertificate("CN=Imported Root");
var sourcePath = Path.Combine(_testRootPath, "import-source.pem");
await WritePemFileAsync(sourcePath, cert);
var options = CreateOptions();
options.Value.BaseRootPath = _testRootPath;
var store = CreateStore(options);
// Act
await store.ImportRootsAsync(sourcePath, RootType.Fulcio);
// Assert
var targetDir = Path.Combine(_testRootPath, "fulcio");
Directory.Exists(targetDir).Should().BeTrue();
Directory.EnumerateFiles(targetDir, "*.pem").Should().HaveCount(1);
}
[Fact]
public async Task ImportRootsAsync_WithMissingFile_ThrowsFileNotFoundException()
{
// Arrange
var options = CreateOptions();
var store = CreateStore(options);
// Act & Assert
await Assert.ThrowsAsync<FileNotFoundException>(
() => store.ImportRootsAsync("/nonexistent/path.pem", RootType.Fulcio));
}
[Fact]
public async Task ImportRootsAsync_InvalidatesCacheAfterImport()
{
// Arrange
var cert1 = CreateTestCertificate("CN=Initial Root");
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
Directory.CreateDirectory(fulcioDir);
await WritePemFileAsync(Path.Combine(fulcioDir, "initial.pem"), cert1);
var options = CreateOptions(fulcioPath: fulcioDir);
options.Value.BaseRootPath = _testRootPath;
var store = CreateStore(options);
// Load initial cache
var initialRoots = await store.GetFulcioRootsAsync();
initialRoots.Should().HaveCount(1);
// Import a new certificate
var cert2 = CreateTestCertificate("CN=Imported Root");
var importPath = Path.Combine(_testRootPath, "import.pem");
await WritePemFileAsync(importPath, cert2);
// Act
await store.ImportRootsAsync(importPath, RootType.Fulcio);
var updatedRoots = await store.GetFulcioRootsAsync();
// Assert - cache invalidated and new cert loaded
updatedRoots.Should().HaveCount(2);
}
[Fact]
public async Task ListRootsAsync_ReturnsCorrectInfo()
{
// Arrange
var cert = CreateTestCertificate("CN=Listed Root");
var fulcioDir = Path.Combine(_testRootPath, "fulcio");
Directory.CreateDirectory(fulcioDir);
await WritePemFileAsync(Path.Combine(fulcioDir, "root.pem"), cert);
var options = CreateOptions(fulcioPath: fulcioDir);
var store = CreateStore(options);
// Act
var roots = await store.ListRootsAsync(RootType.Fulcio);
// Assert
roots.Should().HaveCount(1);
roots[0].Subject.Should().Be("CN=Listed Root");
roots[0].RootType.Should().Be(RootType.Fulcio);
roots[0].Thumbprint.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task GetOrgKeyByIdAsync_WithMatchingThumbprint_ReturnsCertificate()
{
// Arrange
var cert = CreateTestCertificate("CN=Org Signing Key");
var orgDir = Path.Combine(_testRootPath, "org-signing");
Directory.CreateDirectory(orgDir);
await WritePemFileAsync(Path.Combine(orgDir, "org.pem"), cert);
var options = CreateOptions(orgSigningPath: orgDir);
var store = CreateStore(options);
// First, verify the cert was loaded and get its thumbprint from listing
var orgKeys = await store.GetOrgSigningKeysAsync();
orgKeys.Should().HaveCount(1);
// Get the thumbprint from the loaded certificate
var thumbprint = ComputeThumbprint(orgKeys[0]);
// Act
var found = await store.GetOrgKeyByIdAsync(thumbprint);
// Assert
found.Should().NotBeNull();
found!.Subject.Should().Be("CN=Org Signing Key");
}
[Fact]
public async Task GetOrgKeyByIdAsync_WithNoMatch_ReturnsNull()
{
// Arrange
var cert = CreateTestCertificate("CN=Org Key");
var orgDir = Path.Combine(_testRootPath, "org-signing");
Directory.CreateDirectory(orgDir);
await WritePemFileAsync(Path.Combine(orgDir, "org.pem"), cert);
var options = CreateOptions(orgSigningPath: orgDir);
var store = CreateStore(options);
// Act
var found = await store.GetOrgKeyByIdAsync("nonexistent-key-id");
// Assert
found.Should().BeNull();
}
[Fact]
public async Task GetRekorKeysAsync_WithPemFile_ReturnsCertificates()
{
// Arrange
var cert = CreateTestCertificate("CN=Rekor Key");
var rekorPath = Path.Combine(_testRootPath, "rekor.pem");
await WritePemFileAsync(rekorPath, cert);
var options = CreateOptions(rekorPath: rekorPath);
var store = CreateStore(options);
// Act
var keys = await store.GetRekorKeysAsync();
// Assert
keys.Should().HaveCount(1);
keys[0].Subject.Should().Be("CN=Rekor Key");
}
[Fact]
public async Task LoadPem_WithMultipleCertificates_ReturnsAll()
{
// Arrange
var cert1 = CreateTestCertificate("CN=Cert 1");
var cert2 = CreateTestCertificate("CN=Cert 2");
var cert3 = CreateTestCertificate("CN=Cert 3");
var pemPath = Path.Combine(_testRootPath, "multi.pem");
await WriteMultiplePemFileAsync(pemPath, [cert1, cert2, cert3]);
var options = CreateOptions(fulcioPath: pemPath);
var store = CreateStore(options);
// Act
var roots = await store.GetFulcioRootsAsync();
// Assert
roots.Should().HaveCount(3);
}
[Fact]
public async Task GetFulcioRootsAsync_WithOfflineKitPath_LoadsFromKit()
{
// Arrange
var offlineKitPath = Path.Combine(_testRootPath, "offline-kit");
var fulcioKitDir = Path.Combine(offlineKitPath, "roots", "fulcio");
Directory.CreateDirectory(fulcioKitDir);
var cert = CreateTestCertificate("CN=Offline Kit Root");
await WritePemFileAsync(Path.Combine(fulcioKitDir, "root.pem"), cert);
var options = Options.Create(new OfflineRootStoreOptions
{
BaseRootPath = _testRootPath,
OfflineKitPath = offlineKitPath,
UseOfflineKit = true
});
var store = CreateStore(options);
// Act
var roots = await store.GetFulcioRootsAsync();
// Assert
roots.Should().HaveCount(1);
roots[0].Subject.Should().Be("CN=Offline Kit Root");
}
private FileSystemRootStore CreateStore(IOptions<OfflineRootStoreOptions> options)
{
return new FileSystemRootStore(_loggerMock.Object, options);
}
private IOptions<OfflineRootStoreOptions> CreateOptions(
string? fulcioPath = null,
string? orgSigningPath = null,
string? rekorPath = null)
{
return Options.Create(new OfflineRootStoreOptions
{
BaseRootPath = _testRootPath,
FulcioBundlePath = fulcioPath,
OrgSigningBundlePath = orgSigningPath,
RekorBundlePath = rekorPath
});
}
private static X509Certificate2 CreateTestCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
subject,
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add basic constraints for a CA certificate
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(true, false, 0, true));
// Add Subject Key Identifier
request.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
var notAfter = DateTimeOffset.UtcNow.AddYears(10);
return request.CreateSelfSigned(notBefore, notAfter);
}
private static async Task WritePemFileAsync(string path, X509Certificate2 cert)
{
var pem = new StringBuilder();
pem.AppendLine("-----BEGIN CERTIFICATE-----");
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
pem.AppendLine("-----END CERTIFICATE-----");
await File.WriteAllTextAsync(path, pem.ToString());
}
private static async Task WriteMultiplePemFileAsync(string path, X509Certificate2[] certs)
{
var pem = new StringBuilder();
foreach (var cert in certs)
{
pem.AppendLine("-----BEGIN CERTIFICATE-----");
pem.AppendLine(Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks));
pem.AppendLine("-----END CERTIFICATE-----");
pem.AppendLine();
}
await File.WriteAllTextAsync(path, pem.ToString());
}
private static string ComputeThumbprint(X509Certificate2 cert)
{
var hash = SHA256.HashData(cert.RawData);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,486 @@
// -----------------------------------------------------------------------------
// OfflineCertChainValidatorTests.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0022 - Unit tests for certificate chain validation
// Description: Unit tests for offline certificate chain validation
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Bundling.Models;
using StellaOps.Attestor.Offline.Abstractions;
using StellaOps.Attestor.Offline.Models;
using StellaOps.Attestor.Offline.Services;
using StellaOps.Attestor.ProofChain.Merkle;
namespace StellaOps.Attestor.Offline.Tests;
public class OfflineCertChainValidatorTests
{
private readonly Mock<ILogger<OfflineVerifier>> _loggerMock;
private readonly IMerkleTreeBuilder _merkleBuilder;
private readonly IOptions<OfflineVerificationConfig> _config;
public OfflineCertChainValidatorTests()
{
_loggerMock = new Mock<ILogger<OfflineVerifier>>();
_merkleBuilder = new DeterministicMerkleTreeBuilder();
_config = Options.Create(new OfflineVerificationConfig());
}
[Fact]
public async Task VerifyAttestation_WithValidCertChain_ChainIsValid()
{
// Arrange
var (rootCert, leafCert) = CreateCertificateChain();
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeTrue();
result.Issues.Should().NotContain(i => i.Code.Contains("CERT"));
}
[Fact]
public async Task VerifyAttestation_WithUntrustedRoot_ChainIsInvalid()
{
// Arrange
var (rootCert, leafCert) = CreateCertificateChain();
var untrustedRoot = CreateSelfSignedCertificate("CN=Untrusted Root CA");
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
// Root store has a different root
var rootStore = CreateRootStoreWithCerts(new[] { untrustedRoot });
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
}
[Fact]
public async Task VerifyAttestation_WithMissingCertChain_ReturnsIssue()
{
// Arrange
var attestation = CreateAttestationWithoutCertChain();
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT") || i.Code.Contains("CHAIN"));
}
[Fact]
public async Task VerifyAttestation_WithExpiredCert_ChainIsInvalid()
{
// Arrange
var expiredCert = CreateExpiredCertificate("CN=Expired Leaf");
var rootCert = CreateSelfSignedCertificate("CN=Test Root CA");
var attestation = CreateAttestationWithCertChain(expiredCert, rootCert);
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
}
[Fact]
public async Task VerifyAttestation_WithNotYetValidCert_ChainIsInvalid()
{
// Arrange
var futureCert = CreateFutureCertificate("CN=Future Leaf");
var rootCert = CreateSelfSignedCertificate("CN=Test Root CA");
var attestation = CreateAttestationWithCertChain(futureCert, rootCert);
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code.StartsWith("CERT"));
}
[Fact]
public async Task VerifyBundle_WithMultipleAttestations_ValidatesCertChainsForAll()
{
// Arrange
var (rootCert, leafCert1) = CreateCertificateChain();
var attestation1 = CreateAttestationWithCertChain(leafCert1, rootCert, "entry-001");
var attestation2 = CreateAttestationWithCertChain(leafCert1, rootCert, "entry-002");
var bundle = CreateBundleFromAttestations(new[] { attestation1, attestation2 });
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: true,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyBundleAsync(bundle, options);
// Assert
result.CertificateChainValid.Should().BeTrue();
}
[Fact]
public async Task VerifyAttestation_CertChainValidationSkipped_WhenDisabled()
{
// Arrange
var attestation = CreateAttestationWithoutCertChain();
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: false); // Disabled
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert - When cert chain validation is disabled, it should not report cert-related issues
result.Issues.Should().NotContain(i => i.Code.Contains("CERT_CHAIN"));
}
[Fact]
public async Task VerifyAttestation_WithSelfSignedLeaf_ChainIsInvalid()
{
// Arrange
var selfSignedLeaf = CreateSelfSignedCertificate("CN=Self Signed Leaf");
var rootCert = CreateSelfSignedCertificate("CN=Different Root CA");
var attestation = CreateAttestationWithCertChain(selfSignedLeaf);
var rootStore = CreateRootStoreWithCerts(new[] { rootCert });
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeFalse();
}
[Fact]
public async Task VerifyAttestation_WithEmptyRootStore_ChainIsInvalid()
{
// Arrange
var (rootCert, leafCert) = CreateCertificateChain();
var attestation = CreateAttestationWithCertChain(leafCert, rootCert);
var rootStore = CreateRootStoreWithCerts(Array.Empty<X509Certificate2>());
var verifier = CreateVerifier(rootStore);
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: true);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.CertificateChainValid.Should().BeFalse();
}
private OfflineVerifier CreateVerifier(IOfflineRootStore rootStore)
{
return new OfflineVerifier(
rootStore,
_merkleBuilder,
_loggerMock.Object,
_config,
null);
}
private static IOfflineRootStore CreateRootStoreWithCerts(X509Certificate2[] certs)
{
var mock = new Mock<IOfflineRootStore>();
mock.Setup(x => x.GetFulcioRootsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new X509Certificate2Collection(certs));
mock.Setup(x => x.GetOrgSigningKeysAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new X509Certificate2Collection());
mock.Setup(x => x.GetRekorKeysAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new X509Certificate2Collection());
return mock.Object;
}
private static (X509Certificate2 Root, X509Certificate2 Leaf) CreateCertificateChain()
{
using var rootKey = RSA.Create(2048);
var rootRequest = new CertificateRequest(
"CN=Test Fulcio Root CA",
rootKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
rootRequest.CertificateExtensions.Add(
new X509BasicConstraintsExtension(true, true, 1, true));
rootRequest.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
var rootCert = rootRequest.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow.AddYears(10));
using var leafKey = RSA.Create(2048);
var leafRequest = new CertificateRequest(
"CN=Sigstore Signer",
leafKey,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
leafRequest.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, true));
leafRequest.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
var leafCert = leafRequest.Create(
rootCert,
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddMinutes(10),
Guid.NewGuid().ToByteArray());
return (rootCert, leafCert);
}
private static X509Certificate2 CreateSelfSignedCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
subject,
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(true, false, 0, true));
return request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-30),
DateTimeOffset.UtcNow.AddYears(10));
}
private static X509Certificate2 CreateExpiredCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
subject,
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
return request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-365),
DateTimeOffset.UtcNow.AddDays(-1));
}
private static X509Certificate2 CreateFutureCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
subject,
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
return request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(1),
DateTimeOffset.UtcNow.AddYears(1));
}
private static BundledAttestation CreateAttestationWithCertChain(
X509Certificate2 leafCert,
X509Certificate2? rootCert = null,
string entryId = "entry-001")
{
var certChain = new List<string> { ConvertToPem(leafCert) };
if (rootCert != null)
{
certChain.Add(ConvertToPem(rootCert));
}
return new BundledAttestation
{
EntryId = entryId,
RekorUuid = Guid.NewGuid().ToString("N"),
RekorLogIndex = 10000,
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
PredicateType = "verdict.stella/v1",
SignedAt = DateTimeOffset.UtcNow,
SigningMode = "keyless",
SigningIdentity = new SigningIdentity
{
Issuer = "https://authority.internal",
Subject = "signer@stella-ops.org",
San = "urn:stellaops:signer"
},
InclusionProof = new RekorInclusionProof
{
Checkpoint = new CheckpointData
{
Origin = "rekor.sigstore.dev",
Size = 100000,
RootHash = Convert.ToBase64String(new byte[32]),
Timestamp = DateTimeOffset.UtcNow
},
Path = new List<string>
{
Convert.ToBase64String(new byte[32]),
Convert.ToBase64String(new byte[32])
}
},
Envelope = new DsseEnvelopeData
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
Signatures = new List<EnvelopeSignature>
{
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
},
CertificateChain = certChain
}
};
}
private static BundledAttestation CreateAttestationWithoutCertChain()
{
return new BundledAttestation
{
EntryId = "entry-no-chain",
RekorUuid = Guid.NewGuid().ToString("N"),
RekorLogIndex = 10000,
ArtifactDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
PredicateType = "verdict.stella/v1",
SignedAt = DateTimeOffset.UtcNow,
SigningMode = "keyless",
SigningIdentity = new SigningIdentity
{
Issuer = "https://authority.internal",
Subject = "signer@stella-ops.org",
San = "urn:stellaops:signer"
},
InclusionProof = new RekorInclusionProof
{
Checkpoint = new CheckpointData
{
Origin = "rekor.sigstore.dev",
Size = 100000,
RootHash = Convert.ToBase64String(new byte[32]),
Timestamp = DateTimeOffset.UtcNow
},
Path = new List<string>()
},
Envelope = new DsseEnvelopeData
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
Signatures = new List<EnvelopeSignature>
{
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
},
CertificateChain = null
}
};
}
private AttestationBundle CreateBundleFromAttestations(BundledAttestation[] attestations)
{
var sortedAttestations = attestations
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
.ToList();
var leafValues = sortedAttestations
.Select(a => (ReadOnlyMemory<byte>)System.Text.Encoding.UTF8.GetBytes(a.EntryId))
.ToList();
var merkleRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
var merkleRootHex = $"sha256:{Convert.ToHexString(merkleRoot).ToLowerInvariant()}";
return new AttestationBundle
{
Metadata = new BundleMetadata
{
BundleId = merkleRootHex,
Version = "1.0",
CreatedAt = DateTimeOffset.UtcNow,
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
PeriodEnd = DateTimeOffset.UtcNow,
AttestationCount = attestations.Length
},
Attestations = attestations,
MerkleTree = new MerkleTreeInfo
{
Algorithm = "SHA256",
Root = merkleRootHex,
LeafCount = attestations.Length
}
};
}
private static string ConvertToPem(X509Certificate2 cert)
{
var base64 = Convert.ToBase64String(cert.RawData);
return $"-----BEGIN CERTIFICATE-----\n{base64}\n-----END CERTIFICATE-----";
}
}

View File

@@ -0,0 +1,401 @@
// -----------------------------------------------------------------------------
// OfflineVerifierTests.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0019-0022 - Unit tests for offline verification
// Description: Unit tests for OfflineVerifier service
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Models;
using StellaOps.Attestor.Offline.Abstractions;
using StellaOps.Attestor.Offline.Models;
using StellaOps.Attestor.Offline.Services;
using StellaOps.Attestor.ProofChain.Merkle;
// Alias to resolve ambiguity
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
namespace StellaOps.Attestor.Offline.Tests;
public class OfflineVerifierTests
{
private readonly Mock<IOfflineRootStore> _rootStoreMock;
private readonly IMerkleTreeBuilder _merkleBuilder;
private readonly Mock<IOrgKeySigner> _orgSignerMock;
private readonly Mock<ILogger<OfflineVerifier>> _loggerMock;
private readonly IOptions<OfflineVerificationConfig> _config;
public OfflineVerifierTests()
{
_rootStoreMock = new Mock<IOfflineRootStore>();
_merkleBuilder = new DeterministicMerkleTreeBuilder();
_orgSignerMock = new Mock<IOrgKeySigner>();
_loggerMock = new Mock<ILogger<OfflineVerifier>>();
_config = Options.Create(new OfflineVerificationConfig());
// Setup default root store behavior
_rootStoreMock
.Setup(x => x.GetFulcioRootsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new X509Certificate2Collection());
}
[Fact]
public async Task VerifyBundleAsync_ValidBundle_ReturnsValid()
{
// Arrange
var bundle = CreateTestBundle(5);
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: true,
VerifySignatures: false, // Skip signature verification for this test
VerifyCertificateChain: false,
VerifyOrgSignature: false);
// Act
var result = await verifier.VerifyBundleAsync(bundle, options);
// Assert
result.Valid.Should().BeTrue();
result.MerkleProofValid.Should().BeTrue();
result.Issues.Should().BeEmpty();
}
[Fact]
public async Task VerifyBundleAsync_TamperedMerkleRoot_ReturnsInvalid()
{
// Arrange
var bundle = CreateTestBundle(5);
// Tamper with the Merkle root
var tamperedBundle = bundle with
{
MerkleTree = new MerkleTreeInfo
{
Algorithm = "SHA256",
Root = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
LeafCount = 5
}
};
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: true,
VerifySignatures: false,
VerifyCertificateChain: false);
// Act
var result = await verifier.VerifyBundleAsync(tamperedBundle, options);
// Assert
result.Valid.Should().BeFalse();
result.MerkleProofValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code == "MERKLE_ROOT_MISMATCH");
}
[Fact]
public async Task VerifyBundleAsync_MissingOrgSignature_WhenRequired_ReturnsInvalid()
{
// Arrange
var bundle = CreateTestBundle(3);
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: false,
VerifyCertificateChain: false,
VerifyOrgSignature: true,
RequireOrgSignature: true);
// Act
var result = await verifier.VerifyBundleAsync(bundle, options);
// Assert
result.Valid.Should().BeFalse();
result.OrgSignatureValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code == "ORG_SIG_MISSING");
}
[Fact]
public async Task VerifyBundleAsync_WithValidOrgSignature_ReturnsValid()
{
// Arrange
var bundle = CreateTestBundle(3);
var orgSignature = new OrgSignature
{
KeyId = "org-key-2025",
Algorithm = "ECDSA_P256",
Signature = Convert.ToBase64String(new byte[64]),
SignedAt = DateTimeOffset.UtcNow,
CertificateChain = null
};
var signedBundle = bundle with { OrgSignature = orgSignature };
_orgSignerMock
.Setup(x => x.VerifyBundleAsync(It.IsAny<byte[]>(), orgSignature, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: true,
VerifySignatures: false,
VerifyCertificateChain: false,
VerifyOrgSignature: true);
// Act
var result = await verifier.VerifyBundleAsync(signedBundle, options);
// Assert
result.Valid.Should().BeTrue();
result.OrgSignatureValid.Should().BeTrue();
result.OrgSignatureKeyId.Should().Be("org-key-2025");
}
[Fact]
public async Task VerifyAttestationAsync_ValidAttestation_ReturnsValid()
{
// Arrange
var attestation = CreateTestAttestation("entry-001");
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: true,
VerifyCertificateChain: false);
// Act
var result = await verifier.VerifyAttestationAsync(attestation, options);
// Assert
result.Valid.Should().BeTrue();
result.SignaturesValid.Should().BeTrue();
}
[Fact]
public async Task VerifyAttestationAsync_EmptySignature_ReturnsInvalid()
{
// Arrange
var attestation = CreateTestAttestation("entry-001");
// Remove signatures
var tamperedAttestation = attestation with
{
Envelope = attestation.Envelope with
{
Signatures = new List<EnvelopeSignature>()
}
};
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: true,
VerifyCertificateChain: false);
// Act
var result = await verifier.VerifyAttestationAsync(tamperedAttestation, options);
// Assert
result.Valid.Should().BeFalse();
result.SignaturesValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Code == "DSSE_NO_SIGNATURES");
}
[Fact]
public async Task GetVerificationSummariesAsync_ReturnsAllAttestations()
{
// Arrange
var bundle = CreateTestBundle(10);
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: false,
VerifySignatures: true,
VerifyCertificateChain: false);
// Act
var summaries = await verifier.GetVerificationSummariesAsync(bundle, options);
// Assert
summaries.Should().HaveCount(10);
summaries.Should().OnlyContain(s => s.VerificationStatus == AttestationVerificationStatus.Valid);
}
[Fact]
public async Task VerifyBundleAsync_StrictMode_FailsOnWarnings()
{
// Arrange
var attestation = CreateTestAttestation("entry-001");
// Add inclusion proof with empty path to trigger warning
var attestationWithEmptyProof = attestation with
{
InclusionProof = new RekorInclusionProof
{
Checkpoint = new CheckpointData
{
Origin = "rekor.sigstore.dev",
Size = 100000,
RootHash = Convert.ToBase64String(new byte[32]),
Timestamp = DateTimeOffset.UtcNow
},
Path = new List<string>() // Empty path triggers warning
}
};
var bundle = CreateTestBundleFromAttestations(new[] { attestationWithEmptyProof });
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: true,
VerifySignatures: true, // Needs to be true to check attestation-level proofs
VerifyCertificateChain: false,
StrictMode: true);
// Act
var result = await verifier.VerifyBundleAsync(bundle, options);
// Assert
result.Valid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Severity == Severity.Warning);
}
[Fact]
public async Task VerifyBundleAsync_DeterministicOrdering_SameMerkleValidation()
{
// Arrange
var attestations = Enumerable.Range(0, 10)
.Select(i => CreateTestAttestation($"entry-{i:D4}"))
.ToArray();
// Create bundles with same attestations but different initial orders
var bundle1 = CreateTestBundleFromAttestations(attestations.OrderBy(_ => Guid.NewGuid()).ToArray());
var bundle2 = CreateTestBundleFromAttestations(attestations.OrderByDescending(a => a.EntryId).ToArray());
var verifier = CreateVerifier();
var options = new OfflineVerificationOptions(
VerifyMerkleProof: true,
VerifySignatures: false,
VerifyCertificateChain: false);
// Act
var result1 = await verifier.VerifyBundleAsync(bundle1, options);
var result2 = await verifier.VerifyBundleAsync(bundle2, options);
// Assert - both should have the same merkle validation result
result1.MerkleProofValid.Should().Be(result2.MerkleProofValid);
}
private OfflineVerifier CreateVerifier()
{
return new OfflineVerifier(
_rootStoreMock.Object,
_merkleBuilder,
_loggerMock.Object,
_config,
_orgSignerMock.Object);
}
private AttestationBundle CreateTestBundle(int attestationCount)
{
var attestations = Enumerable.Range(0, attestationCount)
.Select(i => CreateTestAttestation($"entry-{i:D4}"))
.ToList();
return CreateTestBundleFromAttestations(attestations.ToArray());
}
private AttestationBundle CreateTestBundleFromAttestations(BundledAttestation[] attestations)
{
// Sort deterministically for Merkle tree
var sortedAttestations = attestations
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
.ToList();
// Compute Merkle root
var leafValues = sortedAttestations
.Select(a => (ReadOnlyMemory<byte>)System.Text.Encoding.UTF8.GetBytes(a.EntryId))
.ToList();
var merkleRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
var merkleRootHex = $"sha256:{Convert.ToHexString(merkleRoot).ToLowerInvariant()}";
return new AttestationBundle
{
Metadata = new BundleMetadata
{
BundleId = merkleRootHex,
Version = "1.0",
CreatedAt = DateTimeOffset.UtcNow,
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
PeriodEnd = DateTimeOffset.UtcNow,
AttestationCount = attestations.Length
},
Attestations = attestations,
MerkleTree = new MerkleTreeInfo
{
Algorithm = "SHA256",
Root = merkleRootHex,
LeafCount = attestations.Length
}
};
}
private static BundledAttestation CreateTestAttestation(string entryId)
{
return new BundledAttestation
{
EntryId = entryId,
RekorUuid = Guid.NewGuid().ToString("N"),
RekorLogIndex = 10000,
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
PredicateType = "verdict.stella/v1",
SignedAt = DateTimeOffset.UtcNow,
SigningMode = "keyless",
SigningIdentity = new SigningIdentity
{
Issuer = "https://authority.internal",
Subject = "signer@stella-ops.org",
San = "urn:stellaops:signer"
},
InclusionProof = new RekorInclusionProof
{
Checkpoint = new CheckpointData
{
Origin = "rekor.sigstore.dev",
Size = 100000,
RootHash = Convert.ToBase64String(new byte[32]),
Timestamp = DateTimeOffset.UtcNow
},
Path = new List<string>
{
Convert.ToBase64String(new byte[32]),
Convert.ToBase64String(new byte[32])
}
},
Envelope = new DsseEnvelopeData
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
Signatures = new List<EnvelopeSignature>
{
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
},
CertificateChain = new List<string>
{
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
}
}
};
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Attestor.Offline.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Offline\StellaOps.Attestor.Offline.csproj" />
</ItemGroup>
</Project>