using System; using System.Collections.Generic; using System.Text; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using StellaOps.Provenance.Attestation; using Xunit; using StellaOps.TestKit; namespace StellaOps.Provenance.Attestation.Tests; public class CosignAndKmsSignerTests { private sealed class FakeCosignClient : ICosignClient { public List<(byte[] payload, string contentType, string keyRef)> Calls { get; } = new(); public Task SignAsync(byte[] payload, string contentType, string keyRef, CancellationToken cancellationToken) { Calls.Add((payload, contentType, keyRef)); return Task.FromResult(Encoding.UTF8.GetBytes("cosign-" + keyRef)); } } private sealed class FakeKmsClient : IKmsClient { public List<(byte[] payload, string contentType, string keyId)> Calls { get; } = new(); public Task SignAsync(byte[] payload, string contentType, string keyId, CancellationToken cancellationToken) { Calls.Add((payload, contentType, keyId)); return Task.FromResult(Encoding.UTF8.GetBytes("kms-" + keyId)); } } private sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _now; public FixedTimeProvider(DateTimeOffset now) => _now = now; public override DateTimeOffset GetUtcNow() => _now; } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CosignSigner_enforces_required_claims_and_logs() { var client = new FakeCosignClient(); var audit = new InMemoryAuditSink(); var signer = new CosignSigner("cosign-key", client, audit, new FixedTimeProvider(DateTimeOffset.UnixEpoch)); var request = new SignRequest( Payload: Encoding.UTF8.GetBytes("payload"), ContentType: "application/vnd.dsse", Claims: new Dictionary { ["sub"] = "artifact" }, RequiredClaims: new[] { "sub" }); var result = await signer.SignAsync(request); result.KeyId.Should().Be("cosign-key"); result.Signature.Should().BeEquivalentTo(Encoding.UTF8.GetBytes("cosign-cosign-key")); audit.Signed.Should().ContainSingle(); client.Calls.Should().ContainSingle(call => call.keyRef == "cosign-key" && call.contentType == "application/vnd.dsse"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CosignSigner_throws_on_missing_required_claim() { var client = new FakeCosignClient(); var audit = new InMemoryAuditSink(); var signer = new CosignSigner("cosign-key", client, audit, new FixedTimeProvider(DateTimeOffset.UnixEpoch)); var request = new SignRequest( Payload: Encoding.UTF8.GetBytes("payload"), ContentType: "application/vnd.dsse", Claims: new Dictionary(), RequiredClaims: new[] { "sub" }); var ex = await Assert.ThrowsAsync(() => signer.SignAsync(request)); ex.Message.Should().Contain("sub"); audit.Missing.Should().ContainSingle(m => m.claim == "sub"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task KmsSigner_signs_with_current_key_and_logs() { var kms = new FakeKmsClient(); var key = new InMemoryKeyProvider("kms-key-1", Encoding.UTF8.GetBytes("secret-kms")); var audit = new InMemoryAuditSink(); var signer = new KmsSigner(kms, key, audit, new FixedTimeProvider(DateTimeOffset.UnixEpoch)); var request = new SignRequest(Encoding.UTF8.GetBytes("payload"), "application/vnd.dsse"); var result = await signer.SignAsync(request); result.KeyId.Should().Be("kms-key-1"); result.Signature.Should().BeEquivalentTo(Encoding.UTF8.GetBytes("kms-kms-key-1")); audit.Signed.Should().ContainSingle(); kms.Calls.Should().ContainSingle(call => call.keyId == "kms-key-1"); } }