using Microsoft.Extensions.Logging.Abstractions; using StellaOps.TaskRunner.Core.Attestation; using StellaOps.TaskRunner.Core.Events; using StellaOps.TaskRunner.Core.Evidence; using StellaOps.TestKit; namespace StellaOps.TaskRunner.Tests; public sealed class PackRunAttestationTests { [Trait("Category", TestCategories.Unit)] [Fact] public async Task GenerateAsync_CreatesAttestationWithSubjects() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var subjects = new List { new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }), new("artifact/sbom.json", new Dictionary { ["sha256"] = "def456" }) }; var request = new PackRunAttestationRequest( RunId: "run-001", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: subjects, EvidenceSnapshotId: Guid.NewGuid(), StartedAt: DateTimeOffset.UtcNow.AddMinutes(-5), CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.NotNull(result.Attestation); Assert.Equal(PackRunAttestationStatus.Signed, result.Attestation.Status); Assert.Equal(2, result.Attestation.Subjects.Count); Assert.NotNull(result.Attestation.Envelope); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GenerateAsync_WithoutSigner_CreatesPendingAttestation() { var store = new InMemoryPackRunAttestationStore(); var service = new PackRunAttestationService( store, NullLogger.Instance); var subjects = new List { new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) }; var request = new PackRunAttestationRequest( RunId: "run-002", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: subjects, EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: null, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.NotNull(result.Attestation); Assert.Equal(PackRunAttestationStatus.Pending, result.Attestation.Status); Assert.Null(result.Attestation.Envelope); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GenerateAsync_EmitsTimelineEvent() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var timelineSink = new InMemoryPackRunTimelineEventSink(); var emitter = new PackRunTimelineEventEmitter( timelineSink, TimeProvider.System, NullLogger.Instance); var service = new PackRunAttestationService( store, NullLogger.Instance, signer, emitter); var request = new PackRunAttestationRequest( RunId: "run-003", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: [new("artifact/test.json", new Dictionary { ["sha256"] = "abc" })], EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.Equal(1, timelineSink.Count); var evt = timelineSink.GetEvents()[0]; Assert.Equal(PackRunAttestationEventTypes.AttestationCreated, evt.EventType); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task VerifyAsync_ValidatesSubjectsMatch() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var subjects = new List { new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) }; var request = new PackRunAttestationRequest( RunId: "run-004", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: subjects, EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.NotNull(genResult.Attestation); var verifyResult = await service.VerifyAsync( new PackRunAttestationVerificationRequest( AttestationId: genResult.Attestation.AttestationId, ExpectedSubjects: subjects, VerifySignature: true, VerifySubjects: true, CheckRevocation: true), TestContext.Current.CancellationToken); Assert.True(verifyResult.Valid); Assert.Equal(PackRunSignatureVerificationStatus.Valid, verifyResult.SignatureStatus); Assert.Equal(PackRunSubjectVerificationStatus.Match, verifyResult.SubjectStatus); Assert.Equal(PackRunRevocationStatus.NotRevoked, verifyResult.RevocationStatus); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task VerifyAsync_DetectsMismatchedSubjects() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var subjects = new List { new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) }; var request = new PackRunAttestationRequest( RunId: "run-005", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: subjects, EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.NotNull(genResult.Attestation); // Verify with different expected subjects var differentSubjects = new List { new("artifact/different.tar.gz", new Dictionary { ["sha256"] = "xyz789" }) }; var verifyResult = await service.VerifyAsync( new PackRunAttestationVerificationRequest( AttestationId: genResult.Attestation.AttestationId, ExpectedSubjects: differentSubjects, VerifySignature: false, VerifySubjects: true, CheckRevocation: false), TestContext.Current.CancellationToken); Assert.False(verifyResult.Valid); Assert.Equal(PackRunSubjectVerificationStatus.Missing, verifyResult.SubjectStatus); Assert.NotNull(verifyResult.Errors); Assert.Contains(verifyResult.Errors, e => e.Contains("Missing subjects")); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task VerifyAsync_DetectsRevokedAttestation() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var subjects = new List { new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) }; var request = new PackRunAttestationRequest( RunId: "run-006", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: subjects, EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.NotNull(genResult.Attestation); // Revoke the attestation await store.UpdateStatusAsync( genResult.Attestation.AttestationId, PackRunAttestationStatus.Revoked, "Compromised key", TestContext.Current.CancellationToken); var verifyResult = await service.VerifyAsync( new PackRunAttestationVerificationRequest( AttestationId: genResult.Attestation.AttestationId, ExpectedSubjects: null, VerifySignature: false, VerifySubjects: false, CheckRevocation: true), TestContext.Current.CancellationToken); Assert.False(verifyResult.Valid); Assert.Equal(PackRunRevocationStatus.Revoked, verifyResult.RevocationStatus); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task VerifyAsync_ReturnsErrorForNonExistentAttestation() { var store = new InMemoryPackRunAttestationStore(); var service = new PackRunAttestationService( store, NullLogger.Instance); var verifyResult = await service.VerifyAsync( new PackRunAttestationVerificationRequest( AttestationId: Guid.NewGuid(), ExpectedSubjects: null, VerifySignature: false, VerifySubjects: false, CheckRevocation: false), TestContext.Current.CancellationToken); Assert.False(verifyResult.Valid); Assert.NotNull(verifyResult.Errors); Assert.Contains(verifyResult.Errors, e => e.Contains("not found")); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ListByRunAsync_ReturnsAttestationsForRun() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); // Create two attestations for the same run for (var i = 0; i < 2; i++) { var request = new PackRunAttestationRequest( RunId: "run-007", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: [new($"artifact/output{i}.tar.gz", new Dictionary { ["sha256"] = $"hash{i}" })], EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); await service.GenerateAsync(request, TestContext.Current.CancellationToken); } var attestations = await service.ListByRunAsync("tenant-1", "run-007", TestContext.Current.CancellationToken); Assert.Equal(2, attestations.Count); Assert.All(attestations, a => Assert.Equal("run-007", a.RunId)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetEnvelopeAsync_ReturnsEnvelopeForSignedAttestation() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var request = new PackRunAttestationRequest( RunId: "run-008", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: [new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" })], EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: null, Metadata: null); var genResult = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.NotNull(genResult.Attestation); var envelope = await service.GetEnvelopeAsync(genResult.Attestation.AttestationId, TestContext.Current.CancellationToken); Assert.NotNull(envelope); Assert.Equal(PackRunDsseEnvelope.InTotoPayloadType, envelope.PayloadType); Assert.Single(envelope.Signatures); } [Trait("Category", TestCategories.Unit)] [Fact] public void PackRunAttestationSubject_FromArtifact_ParsesSha256Prefix() { var artifact = new PackRunArtifactReference( Name: "output.tar.gz", Sha256: "sha256:abcdef123456", SizeBytes: 1024, MediaType: "application/gzip"); var subject = PackRunAttestationSubject.FromArtifact(artifact); Assert.Equal("output.tar.gz", subject.Name); Assert.Equal("abcdef123456", subject.Digest["sha256"]); } [Trait("Category", TestCategories.Unit)] [Fact] public void PackRunAttestation_ComputeStatementDigest_IsDeterministic() { var subjects = new List { new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc123" }) }; var attestation = new PackRunAttestation( AttestationId: Guid.NewGuid(), TenantId: "tenant-1", RunId: "run-001", PlanHash: "sha256:plan123", CreatedAt: DateTimeOffset.Parse("2025-12-06T00:00:00Z"), Subjects: subjects, PredicateType: PredicateTypes.PackRunProvenance, PredicateJson: "{\"test\":true}", Envelope: null, Status: PackRunAttestationStatus.Pending, Error: null, EvidenceSnapshotId: null, Metadata: null); var digest1 = attestation.ComputeStatementDigest(); var digest2 = attestation.ComputeStatementDigest(); Assert.Equal(digest1, digest2); Assert.StartsWith("sha256:", digest1); } [Trait("Category", TestCategories.Unit)] [Fact] public void PackRunDsseEnvelope_ComputeDigest_IsDeterministic() { var envelope = new PackRunDsseEnvelope( PayloadType: PackRunDsseEnvelope.InTotoPayloadType, Payload: Convert.ToBase64String([1, 2, 3]), Signatures: [new PackRunDsseSignature("key-001", "sig123")]); var digest1 = envelope.ComputeDigest(); var digest2 = envelope.ComputeDigest(); Assert.Equal(digest1, digest2); Assert.StartsWith("sha256:", digest1); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GenerateAsync_WithExternalParameters_IncludesInPredicate() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var externalParams = new Dictionary { ["manifestUrl"] = "https://registry.example.com/pack/v1", ["version"] = "1.0.0" }; var request = new PackRunAttestationRequest( RunId: "run-009", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: [new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc" })], EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: "https://stellaops.io/task-runner/custom", ExternalParameters: externalParams, ResolvedDependencies: null, Metadata: null); var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.NotNull(result.Attestation); Assert.Contains("manifestUrl", result.Attestation.PredicateJson); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GenerateAsync_WithResolvedDependencies_IncludesInPredicate() { var store = new InMemoryPackRunAttestationStore(); var signer = new StubPackRunAttestationSigner(); var service = new PackRunAttestationService( store, NullLogger.Instance, signer); var dependencies = new List { new("https://registry.example.com/tool/scanner:v1", new Dictionary { ["sha256"] = "scanner123" }, "scanner", "application/vnd.oci.image.index.v1+json") }; var request = new PackRunAttestationRequest( RunId: "run-010", TenantId: "tenant-1", PlanHash: "sha256:plan123", Subjects: [new("artifact/output.tar.gz", new Dictionary { ["sha256"] = "abc" })], EvidenceSnapshotId: null, StartedAt: DateTimeOffset.UtcNow, CompletedAt: DateTimeOffset.UtcNow, BuilderId: null, ExternalParameters: null, ResolvedDependencies: dependencies, Metadata: null); var result = await service.GenerateAsync(request, TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.NotNull(result.Attestation); Assert.Contains("resolvedDependencies", result.Attestation.PredicateJson); Assert.Contains("scanner", result.Attestation.PredicateJson); } }