358 lines
12 KiB
C#
358 lines
12 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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<IOptionsMonitor<DriftAttestationOptions>> _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<IOptionsMonitor<DriftAttestationOptions>>();
|
|
_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<ArgumentNullException>(
|
|
() => 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<IDriftSignerClient>();
|
|
signerMock.Setup(x => x.SignAsync(It.IsAny<DriftSignerRequest>(), It.IsAny<CancellationToken>()))
|
|
.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<DriftSignerRequest>(r => r.TenantId == "tenant-1"),
|
|
It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_Returns_Failure_When_Signer_Fails()
|
|
{
|
|
// Arrange
|
|
_options.UseSignerService = true;
|
|
var signerMock = new Mock<IDriftSignerClient>();
|
|
signerMock.Setup(x => x.SignAsync(It.IsAny<DriftSignerRequest>(), It.IsAny<CancellationToken>()))
|
|
.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<DriftAttestationService>.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<DriftedSink>.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<PathNode>.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");
|
|
}
|
|
}
|