using System.Text; using StellaOps.Provenance.Attestation; using Xunit; namespace StellaOps.Provenance.Attestation.Tests; public sealed class PromotionAttestationBuilderTests { [Fact] public async Task BuildAsync_SignsCanonicalPayloadAndAddsPredicateClaim() { var predicate = new PromotionPredicate( ImageDigest: "sha256:deadbeef", SbomDigest: "sha256:sbom", VexDigest: "sha256:vex", PromotionId: "promo-123", RekorEntry: "rekor:entry", Metadata: new Dictionary { { "z", "last" }, { "a", "first" } }); var signer = new RecordingSigner(); var attestation = await PromotionAttestationBuilder.BuildAsync(predicate, signer); Assert.Equal(predicate, attestation.Predicate); Assert.NotNull(attestation.Payload); Assert.Equal(PromotionAttestationBuilder.ContentType, signer.LastRequest?.ContentType); Assert.Equal(PromotionAttestationBuilder.PredicateType, signer.LastRequest?.Claims!["predicateType"]); Assert.Equal(attestation.Payload, signer.LastRequest?.Payload); Assert.Equal(attestation.Signature, signer.LastResult); // verify canonical order is stable (metadata keys sorted, property names sorted) var canonicalJson = Encoding.UTF8.GetString(attestation.Payload); Assert.Equal( "{\"ImageDigest\":\"sha256:deadbeef\",\"Metadata\":{\"a\":\"first\",\"z\":\"last\"},\"PromotionId\":\"promo-123\",\"RekorEntry\":\"rekor:entry\",\"SbomDigest\":\"sha256:sbom\",\"VexDigest\":\"sha256:vex\"}", canonicalJson); } [Fact] public async Task BuildAsync_MergesClaimsWithoutOverwritingPredicateType() { var predicate = new PromotionPredicate( ImageDigest: "sha256:x", SbomDigest: "sha256:y", VexDigest: "sha256:z", PromotionId: "p-1"); var signer = new RecordingSigner(); var customClaims = new Dictionary { { "env", "stage" }, { "predicateType", "custom" } }; await PromotionAttestationBuilder.BuildAsync(predicate, signer, customClaims); Assert.NotNull(signer.LastRequest); Assert.Equal(PromotionAttestationBuilder.PredicateType, signer.LastRequest!.Claims!["predicateType"]); Assert.Equal("stage", signer.LastRequest!.Claims!["env"]); } private sealed class RecordingSigner : ISigner { public SignRequest? LastRequest { get; private set; } public SignResult? LastResult { get; private set; } public Task SignAsync(SignRequest request, CancellationToken cancellationToken = default) { LastRequest = request; LastResult = new SignResult( Signature: Encoding.UTF8.GetBytes("sig"), KeyId: "key-1", SignedAt: DateTimeOffset.UtcNow, Claims: request.Claims); return Task.FromResult(LastResult); } } }