// ----------------------------------------------------------------------------- // DriftAttestationServiceTests.cs // Sprint: SPRINT_3600_0004_0001_ui_evidence_chain // Task: UI-018 // Description: Unit tests for DriftAttestationService. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Moq; using StellaOps.Scanner.Reachability; using StellaOps.Scanner.ReachabilityDrift.Attestation; using Xunit; using StellaOps.TestKit; namespace StellaOps.Scanner.ReachabilityDrift.Tests; public sealed class DriftAttestationServiceTests { private readonly FakeTimeProvider _timeProvider; private readonly Mock> _optionsMock; private readonly DriftAttestationOptions _options; public DriftAttestationServiceTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero)); _options = new DriftAttestationOptions { Enabled = true, UseSignerService = false }; _optionsMock = new Mock>(); _optionsMock.Setup(x => x.CurrentValue).Returns(_options); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Creates_Valid_Attestation() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert result.Success.Should().BeTrue(); result.AttestationDigest.Should().StartWith("sha256:"); result.EnvelopeJson.Should().NotBeNullOrEmpty(); result.KeyId.Should().Be("local-dev-key"); result.CreatedAt.Should().Be(_timeProvider.GetUtcNow()); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Returns_Failure_When_Disabled() { // Arrange _options.Enabled = false; var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("disabled"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Throws_When_Request_Null() { // Arrange var service = CreateService(); // Act & Assert await Assert.ThrowsAsync( () => service.CreateAttestationAsync(null!)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Envelope_Contains_Correct_PayloadType() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert result.EnvelopeJson.Should().Contain("application/vnd.in-toto+json"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Envelope_Contains_Signature() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert var envelope = JsonDocument.Parse(result.EnvelopeJson!); var signatures = envelope.RootElement.GetProperty("signatures"); signatures.GetArrayLength().Should().Be(1); signatures[0].GetProperty("keyid").GetString().Should().Be("local-dev-key"); signatures[0].GetProperty("sig").GetString().Should().NotBeNullOrEmpty(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Statement_Contains_Predicate() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert var envelope = JsonDocument.Parse(result.EnvelopeJson!); var payloadBase64 = envelope.RootElement.GetProperty("payload").GetString(); var payloadBytes = Convert.FromBase64String(payloadBase64!); var statement = JsonDocument.Parse(payloadBytes); statement.RootElement.GetProperty("predicateType").GetString() .Should().Be("stellaops.dev/predicates/reachability-drift@v1"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Predicate_Contains_Drift_Summary() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert var predicate = ExtractPredicate(result.EnvelopeJson!); predicate.GetProperty("drift").GetProperty("newlyReachableCount").GetInt32().Should().Be(1); predicate.GetProperty("drift").GetProperty("newlyUnreachableCount").GetInt32().Should().Be(0); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Predicate_Contains_Image_References() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert var predicate = ExtractPredicate(result.EnvelopeJson!); predicate.GetProperty("baseImage").GetProperty("name").GetString() .Should().Be("myregistry/myapp"); predicate.GetProperty("baseImage").GetProperty("digest").GetString() .Should().Be("sha256:base123"); predicate.GetProperty("targetImage").GetProperty("name").GetString() .Should().Be("myregistry/myapp"); predicate.GetProperty("targetImage").GetProperty("digest").GetString() .Should().Be("sha256:head456"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Predicate_Contains_Analysis_Metadata() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert var predicate = ExtractPredicate(result.EnvelopeJson!); var analysis = predicate.GetProperty("analysis"); analysis.GetProperty("baseGraphDigest").GetString().Should().Be("sha256:graph-base"); analysis.GetProperty("headGraphDigest").GetString().Should().Be("sha256:graph-head"); analysis.GetProperty("scanner").GetProperty("name").GetString().Should().Be("StellaOps.Scanner"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Produces_Deterministic_Digest_For_Same_Input() { // Arrange var service = CreateService(); var request = CreateValidRequest(); // Act var result1 = await service.CreateAttestationAsync(request); var result2 = await service.CreateAttestationAsync(request); // Assert result1.AttestationDigest.Should().Be(result2.AttestationDigest); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_With_Signer_Service_Calls_SignAsync() { // Arrange _options.UseSignerService = true; var signerMock = new Mock(); signerMock.Setup(x => x.SignAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new DriftSignerResult { Success = true, Signature = "base64-signature", KeyId = "test-key-id" }); var service = CreateService(signerMock.Object); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert result.Success.Should().BeTrue(); result.KeyId.Should().Be("test-key-id"); signerMock.Verify(x => x.SignAsync( It.Is(r => r.TenantId == "tenant-1"), It.IsAny()), Times.Once); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateAttestationAsync_Returns_Failure_When_Signer_Fails() { // Arrange _options.UseSignerService = true; var signerMock = new Mock(); signerMock.Setup(x => x.SignAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new DriftSignerResult { Success = false, Error = "Key not found" }); var service = CreateService(signerMock.Object); var request = CreateValidRequest(); // Act var result = await service.CreateAttestationAsync(request); // Assert result.Success.Should().BeFalse(); result.Error.Should().Contain("Key not found"); } private DriftAttestationService CreateService(IDriftSignerClient? signerClient = null) { return new DriftAttestationService( signerClient, _optionsMock.Object, _timeProvider, NullLogger.Instance); } private DriftAttestationRequest CreateValidRequest() { var driftResult = new ReachabilityDriftResult { Id = Guid.NewGuid(), BaseScanId = "scan-base-123", HeadScanId = "scan-head-456", Language = "csharp", DetectedAt = _timeProvider.GetUtcNow(), NewlyReachable = ImmutableArray.Create(CreateDriftedSink()), NewlyUnreachable = ImmutableArray.Empty, ResultDigest = "sha256:result-digest" }; return new DriftAttestationRequest { TenantId = "tenant-1", DriftResult = driftResult, BaseImage = new ImageRef { Name = "myregistry/myapp", Digest = "sha256:base123", Tag = "v1.0.0" }, TargetImage = new ImageRef { Name = "myregistry/myapp", Digest = "sha256:head456", Tag = "v1.1.0" }, BaseGraphDigest = "sha256:graph-base", HeadGraphDigest = "sha256:graph-head" }; } private static DriftedSink CreateDriftedSink() { return new DriftedSink { Id = Guid.NewGuid(), SinkNodeId = "sink-node-1", Symbol = "SqlCommand.ExecuteNonQuery", SinkCategory = SinkCategory.SqlInjection, Direction = DriftDirection.BecameReachable, Cause = new DriftCause { Kind = DriftCauseKind.GuardRemoved, Description = "Security guard was removed from the call path" }, Path = new CompressedPath { Entrypoint = new PathNode { NodeId = "entry-1", Symbol = "Program.Main", IsChanged = false }, Sink = new PathNode { NodeId = "sink-1", Symbol = "SqlCommand.ExecuteNonQuery", IsChanged = false }, KeyNodes = ImmutableArray.Empty, IntermediateCount = 3 } }; } private static JsonElement ExtractPredicate(string envelopeJson) { var envelope = JsonDocument.Parse(envelopeJson); var payloadBase64 = envelope.RootElement.GetProperty("payload").GetString(); var payloadBytes = Convert.FromBase64String(payloadBase64!); var statement = JsonDocument.Parse(payloadBytes); return statement.RootElement.GetProperty("predicate"); } }