using System; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Attestor.Core.Options; using StellaOps.Attestor.Core.Submission; using StellaOps.Attestor.Core.Observability; using StellaOps.Attestor.Infrastructure.Rekor; using StellaOps.Attestor.Infrastructure.Storage; using StellaOps.Attestor.Infrastructure.Submission; using Xunit; namespace StellaOps.Attestor.Tests; public sealed class AttestorSubmissionServiceTests { [Fact] public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle() { var options = Options.Create(new AttestorOptions { Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Rekor = new AttestorOptions.RekorOptions { Primary = new AttestorOptions.RekorBackendOptions { Url = "https://rekor.stellaops.test", ProofTimeoutMs = 1000, PollIntervalMs = 50, MaxAttempts = 2 } } }); var canonicalizer = new DefaultDsseCanonicalizer(); var validator = new AttestorSubmissionValidator(canonicalizer); var repository = new InMemoryAttestorEntryRepository(); var dedupeStore = new InMemoryAttestorDedupeStore(); var rekorClient = new StubRekorClient(new NullLogger()); var archiveStore = new NullAttestorArchiveStore(new NullLogger()); var auditSink = new InMemoryAttestorAuditSink(); var logger = new NullLogger(); using var metrics = new AttestorMetrics(); var service = new AttestorSubmissionService( validator, repository, dedupeStore, rekorClient, archiveStore, auditSink, options, logger, TimeProvider.System, metrics); var request = CreateValidRequest(canonicalizer); var context = new SubmissionContext { CallerSubject = "urn:stellaops:signer", CallerAudience = "attestor", CallerClientId = "signer-service", CallerTenant = "default", ClientCertificate = null, MtlsThumbprint = "00" }; var first = await service.SubmitAsync(request, context); var second = await service.SubmitAsync(request, context); Assert.NotNull(first.Uuid); Assert.Equal(first.Uuid, second.Uuid); var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256); Assert.NotNull(stored); Assert.Equal(first.Uuid, stored!.RekorUuid); } [Fact] public async Task Validator_ThrowsWhenModeNotAllowed() { var canonicalizer = new DefaultDsseCanonicalizer(); var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" }); var request = CreateValidRequest(canonicalizer); request.Bundle.Mode = "keyless"; await Assert.ThrowsAsync(() => validator.ValidateAsync(request)); } [Fact] public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested() { var options = Options.Create(new AttestorOptions { Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Rekor = new AttestorOptions.RekorOptions { Primary = new AttestorOptions.RekorBackendOptions { Url = "https://rekor.primary.test", ProofTimeoutMs = 1000, PollIntervalMs = 50, MaxAttempts = 2 } } }); var canonicalizer = new DefaultDsseCanonicalizer(); var validator = new AttestorSubmissionValidator(canonicalizer); var repository = new InMemoryAttestorEntryRepository(); var dedupeStore = new InMemoryAttestorDedupeStore(); var rekorClient = new StubRekorClient(new NullLogger()); var archiveStore = new NullAttestorArchiveStore(new NullLogger()); var auditSink = new InMemoryAttestorAuditSink(); var logger = new NullLogger(); using var metrics = new AttestorMetrics(); var service = new AttestorSubmissionService( validator, repository, dedupeStore, rekorClient, archiveStore, auditSink, options, logger, TimeProvider.System, metrics); var request = CreateValidRequest(canonicalizer); request.Meta.LogPreference = "mirror"; var context = new SubmissionContext { CallerSubject = "urn:stellaops:signer", CallerAudience = "attestor", CallerClientId = "signer-service", CallerTenant = "default" }; var ex = await Assert.ThrowsAsync(() => service.SubmitAsync(request, context)); Assert.Equal("mirror_disabled", ex.Code); } [Fact] public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth() { var options = Options.Create(new AttestorOptions { Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Rekor = new AttestorOptions.RekorOptions { Primary = new AttestorOptions.RekorBackendOptions { Url = "https://rekor.primary.test", ProofTimeoutMs = 1000, PollIntervalMs = 50, MaxAttempts = 2 }, Mirror = new AttestorOptions.RekorMirrorOptions { Enabled = true, Url = "https://rekor.mirror.test", ProofTimeoutMs = 1000, PollIntervalMs = 50, MaxAttempts = 2 } } }); var canonicalizer = new DefaultDsseCanonicalizer(); var validator = new AttestorSubmissionValidator(canonicalizer); var repository = new InMemoryAttestorEntryRepository(); var dedupeStore = new InMemoryAttestorDedupeStore(); var rekorClient = new StubRekorClient(new NullLogger()); var archiveStore = new NullAttestorArchiveStore(new NullLogger()); var auditSink = new InMemoryAttestorAuditSink(); var logger = new NullLogger(); using var metrics = new AttestorMetrics(); var service = new AttestorSubmissionService( validator, repository, dedupeStore, rekorClient, archiveStore, auditSink, options, logger, TimeProvider.System, metrics); var request = CreateValidRequest(canonicalizer); request.Meta.LogPreference = "both"; var context = new SubmissionContext { CallerSubject = "urn:stellaops:signer", CallerAudience = "attestor", CallerClientId = "signer-service", CallerTenant = "default" }; var result = await service.SubmitAsync(request, context); Assert.NotNull(result.Mirror); Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid)); Assert.Equal("included", result.Mirror.Status); } [Fact] public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror() { var options = Options.Create(new AttestorOptions { Redis = new AttestorOptions.RedisOptions { Url = string.Empty }, Rekor = new AttestorOptions.RekorOptions { Primary = new AttestorOptions.RekorBackendOptions { Url = "https://rekor.primary.test", ProofTimeoutMs = 1000, PollIntervalMs = 50, MaxAttempts = 2 }, Mirror = new AttestorOptions.RekorMirrorOptions { Enabled = true, Url = "https://rekor.mirror.test", ProofTimeoutMs = 1000, PollIntervalMs = 50, MaxAttempts = 2 } } }); var canonicalizer = new DefaultDsseCanonicalizer(); var validator = new AttestorSubmissionValidator(canonicalizer); var repository = new InMemoryAttestorEntryRepository(); var dedupeStore = new InMemoryAttestorDedupeStore(); var rekorClient = new StubRekorClient(new NullLogger()); var archiveStore = new NullAttestorArchiveStore(new NullLogger()); var auditSink = new InMemoryAttestorAuditSink(); var logger = new NullLogger(); using var metrics = new AttestorMetrics(); var service = new AttestorSubmissionService( validator, repository, dedupeStore, rekorClient, archiveStore, auditSink, options, logger, TimeProvider.System, metrics); var request = CreateValidRequest(canonicalizer); request.Meta.LogPreference = "mirror"; var context = new SubmissionContext { CallerSubject = "urn:stellaops:signer", CallerAudience = "attestor", CallerClientId = "signer-service", CallerTenant = "default" }; var result = await service.SubmitAsync(request, context); Assert.NotNull(result.Uuid); var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256); Assert.NotNull(stored); Assert.Equal("mirror", stored!.Log.Backend); Assert.Null(result.Mirror); } private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer) { var request = new AttestorSubmissionRequest { Bundle = new AttestorSubmissionRequest.SubmissionBundle { Mode = "keyless", Dsse = new AttestorSubmissionRequest.DsseEnvelope { PayloadType = "application/vnd.in-toto+json", PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")), Signatures = { new AttestorSubmissionRequest.DsseSignature { KeyId = "test", Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) } } } }, Meta = new AttestorSubmissionRequest.SubmissionMeta { Artifact = new AttestorSubmissionRequest.ArtifactInfo { Sha256 = new string('a', 64), Kind = "sbom" }, LogPreference = "primary", Archive = false } }; var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult(); request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant(); return request; } }