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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-----"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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-----";
|
||||
}
|
||||
}
|
||||
@@ -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-----"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user