327 lines
10 KiB
C#
327 lines
10 KiB
C#
// -----------------------------------------------------------------------------
|
|
// PolicyDecisionAttestationServiceTests.cs
|
|
// Sprint: SPRINT_3801_0001_0001_policy_decision_attestation
|
|
// Description: Unit tests for PolicyDecisionAttestationService.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
using StellaOps.Policy.Engine.Attestation;
|
|
using StellaOps.Policy.Engine.Vex;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Policy.Engine.Tests.Attestation;
|
|
|
|
public class PolicyDecisionAttestationServiceTests
|
|
{
|
|
private readonly Mock<IOptionsMonitor<PolicyDecisionAttestationOptions>> _optionsMock;
|
|
private readonly Mock<IVexSignerClient> _signerClientMock;
|
|
private readonly Mock<IVexRekorClient> _rekorClientMock;
|
|
private readonly PolicyDecisionAttestationService _service;
|
|
|
|
public PolicyDecisionAttestationServiceTests()
|
|
{
|
|
_optionsMock = new Mock<IOptionsMonitor<PolicyDecisionAttestationOptions>>();
|
|
_optionsMock.Setup(x => x.CurrentValue).Returns(new PolicyDecisionAttestationOptions
|
|
{
|
|
Enabled = true,
|
|
UseSignerService = true,
|
|
DefaultTtlHours = 24
|
|
});
|
|
|
|
_signerClientMock = new Mock<IVexSignerClient>();
|
|
_rekorClientMock = new Mock<IVexRekorClient>();
|
|
|
|
_service = new PolicyDecisionAttestationService(
|
|
_signerClientMock.Object,
|
|
_rekorClientMock.Object,
|
|
_optionsMock.Object,
|
|
TimeProvider.System,
|
|
NullLogger<PolicyDecisionAttestationService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_WhenDisabled_ReturnsFailure()
|
|
{
|
|
// Arrange
|
|
_optionsMock.Setup(x => x.CurrentValue).Returns(new PolicyDecisionAttestationOptions
|
|
{
|
|
Enabled = false
|
|
});
|
|
|
|
var request = CreateTestRequest();
|
|
|
|
// Act
|
|
var result = await _service.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Contains("disabled", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_WithSignerClient_CallsSigner()
|
|
{
|
|
// Arrange
|
|
_signerClientMock.Setup(x => x.SignAsync(
|
|
It.IsAny<VexSignerRequest>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexSignerResult
|
|
{
|
|
Success = true,
|
|
Signature = "AQID",
|
|
KeyId = "key-1"
|
|
});
|
|
|
|
var request = CreateTestRequest();
|
|
|
|
// Act
|
|
var result = await _service.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.AttestationDigest);
|
|
Assert.Matches("^sha256:[a-f0-9]{64}$", result.AttestationDigest);
|
|
Assert.Equal("key-1", result.KeyId);
|
|
|
|
_signerClientMock.Verify(x => x.SignAsync(
|
|
It.Is<VexSignerRequest>(r => r.PayloadType == "stella.ops/policy-decision@v1"),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_WhenSigningFails_ReturnsFailure()
|
|
{
|
|
// Arrange
|
|
_signerClientMock.Setup(x => x.SignAsync(
|
|
It.IsAny<VexSignerRequest>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexSignerResult
|
|
{
|
|
Success = false,
|
|
Error = "Key not found"
|
|
});
|
|
|
|
var request = CreateTestRequest();
|
|
|
|
// Act
|
|
var result = await _service.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Key not found", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_WithRekorSubmission_SubmitsToRekor()
|
|
{
|
|
// Arrange
|
|
_signerClientMock.Setup(x => x.SignAsync(
|
|
It.IsAny<VexSignerRequest>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexSignerResult
|
|
{
|
|
Success = true,
|
|
Signature = "AQID",
|
|
KeyId = "key-1"
|
|
});
|
|
|
|
_rekorClientMock.Setup(x => x.SubmitAsync(
|
|
It.IsAny<VexRekorSubmitRequest>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexRekorSubmitResult
|
|
{
|
|
Success = true,
|
|
Metadata = new VexRekorMetadata
|
|
{
|
|
Uuid = "rekor-uuid-123",
|
|
Index = 12345,
|
|
LogUrl = "https://rekor.local/api/v1/log/entries/rekor-uuid-123",
|
|
IntegratedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
|
}
|
|
});
|
|
|
|
var request = CreateTestRequest() with { SubmitToRekor = true };
|
|
|
|
// Act
|
|
var result = await _service.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.RekorResult);
|
|
Assert.True(result.RekorResult.Success);
|
|
Assert.Equal(12345, result.RekorResult.LogIndex);
|
|
Assert.Equal("rekor-uuid-123", result.RekorResult.Uuid);
|
|
|
|
var envelopeDigestHex = result.AttestationDigest!.Substring("sha256:".Length);
|
|
|
|
_rekorClientMock.Verify(x => x.SubmitAsync(
|
|
It.Is<VexRekorSubmitRequest>(r =>
|
|
r.ArtifactKind == "policy-decision" &&
|
|
r.Envelope.PayloadType == PredicateTypes.StellaOpsPolicyDecision &&
|
|
r.EnvelopeDigest == envelopeDigestHex &&
|
|
r.SubjectUris!.Contains("example.com/image:v1@sha256:abc123")),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_WithoutSignerClient_CreatesUnsignedAttestation()
|
|
{
|
|
// Arrange
|
|
var serviceWithoutSigner = new PolicyDecisionAttestationService(
|
|
signerClient: null,
|
|
rekorClient: null,
|
|
_optionsMock.Object,
|
|
TimeProvider.System,
|
|
NullLogger<PolicyDecisionAttestationService>.Instance);
|
|
|
|
var request = CreateTestRequest();
|
|
|
|
// Act
|
|
var result = await serviceWithoutSigner.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.StartsWith("sha256:", result.AttestationDigest);
|
|
Assert.Null(result.KeyId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_IncludesAllSubjects()
|
|
{
|
|
// Arrange
|
|
_signerClientMock.Setup(x => x.SignAsync(
|
|
It.IsAny<VexSignerRequest>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new VexSignerResult
|
|
{
|
|
Success = true,
|
|
Signature = "AQID"
|
|
});
|
|
|
|
var request = CreateTestRequest() with
|
|
{
|
|
Subjects = new[]
|
|
{
|
|
new AttestationSubject
|
|
{
|
|
Name = "example.com/image:v1",
|
|
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
|
|
},
|
|
new AttestationSubject
|
|
{
|
|
Name = "example.com/image:v2",
|
|
Digest = new Dictionary<string, string> { ["sha256"] = "def456" }
|
|
}
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var result = await _service.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAttestationAsync_SetsExpirationFromOptions()
|
|
{
|
|
// Arrange
|
|
_optionsMock.Setup(x => x.CurrentValue).Returns(new PolicyDecisionAttestationOptions
|
|
{
|
|
Enabled = true,
|
|
UseSignerService = false,
|
|
DefaultTtlHours = 48
|
|
});
|
|
|
|
var serviceWithOptions = new PolicyDecisionAttestationService(
|
|
signerClient: null,
|
|
rekorClient: null,
|
|
_optionsMock.Object,
|
|
TimeProvider.System,
|
|
NullLogger<PolicyDecisionAttestationService>.Instance);
|
|
|
|
var request = CreateTestRequest();
|
|
|
|
// Act
|
|
var result = await serviceWithOptions.CreateAttestationAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitToRekorAsync_WhenNoClient_ReturnsFailure()
|
|
{
|
|
// Arrange
|
|
var serviceWithoutRekor = new PolicyDecisionAttestationService(
|
|
_signerClientMock.Object,
|
|
rekorClient: null,
|
|
_optionsMock.Object,
|
|
TimeProvider.System,
|
|
NullLogger<PolicyDecisionAttestationService>.Instance);
|
|
|
|
// Act
|
|
var result = await serviceWithoutRekor.SubmitToRekorAsync("sha256:test");
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Contains("not available", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ReturnsNotImplemented()
|
|
{
|
|
// Act
|
|
var result = await _service.VerifyAsync("sha256:test");
|
|
|
|
// Assert
|
|
Assert.False(result.Valid);
|
|
Assert.Contains("not yet implemented", result.Issues![0], StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static PolicyDecisionAttestationRequest CreateTestRequest()
|
|
{
|
|
return new PolicyDecisionAttestationRequest
|
|
{
|
|
Predicate = new PolicyDecisionPredicate
|
|
{
|
|
Policy = new PolicyReference
|
|
{
|
|
Id = "test-policy",
|
|
Version = "1.0.0",
|
|
Name = "Test Policy"
|
|
},
|
|
Inputs = new PolicyDecisionInputs
|
|
{
|
|
Subjects = new[]
|
|
{
|
|
new SubjectReference
|
|
{
|
|
Name = "example.com/image:v1",
|
|
Digest = "sha256:abc123"
|
|
}
|
|
}
|
|
},
|
|
Result = new PolicyDecisionResult
|
|
{
|
|
Decision = PolicyDecision.Allow,
|
|
Summary = "All gates passed"
|
|
}
|
|
},
|
|
Subjects = new[]
|
|
{
|
|
new AttestationSubject
|
|
{
|
|
Name = "example.com/image:v1",
|
|
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
|
|
}
|
|
},
|
|
TenantId = "tenant-1"
|
|
};
|
|
}
|
|
}
|