Add unit and integration tests for VexCandidateEmitter and SmartDiff repositories
- Implemented comprehensive unit tests for VexCandidateEmitter to validate candidate emission logic based on various scenarios including absent and present APIs, confidence thresholds, and rate limiting. - Added integration tests for SmartDiff PostgreSQL repositories, covering snapshot storage and retrieval, candidate storage, and material risk change handling. - Ensured tests validate correct behavior for storing, retrieving, and querying snapshots and candidates, including edge cases and expected outcomes.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// =============================================================================
|
||||
// RekorRetryWorkerTests.cs
|
||||
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
|
||||
// Task: T11
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Queue;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Infrastructure.Workers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for RekorRetryWorker.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "3000_0001_0002")]
|
||||
public sealed class RekorRetryWorkerTests
|
||||
{
|
||||
private readonly Mock<IRekorSubmissionQueue> _queueMock;
|
||||
private readonly Mock<IRekorClient> _rekorClientMock;
|
||||
private readonly Mock<TimeProvider> _timeProviderMock;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
private readonly RekorQueueOptions _queueOptions;
|
||||
private readonly AttestorOptions _attestorOptions;
|
||||
|
||||
public RekorRetryWorkerTests()
|
||||
{
|
||||
_queueMock = new Mock<IRekorSubmissionQueue>();
|
||||
_rekorClientMock = new Mock<IRekorClient>();
|
||||
_timeProviderMock = new Mock<TimeProvider>();
|
||||
_metrics = new AttestorMetrics();
|
||||
|
||||
_queueOptions = new RekorQueueOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BatchSize = 5,
|
||||
PollIntervalMs = 100,
|
||||
MaxAttempts = 3
|
||||
};
|
||||
|
||||
_attestorOptions = new AttestorOptions
|
||||
{
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.example.com"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_timeProviderMock
|
||||
.Setup(t => t.GetUtcNow())
|
||||
.Returns(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Worker does not process when disabled")]
|
||||
public async Task ExecuteAsync_WhenDisabled_DoesNotProcess()
|
||||
{
|
||||
_queueOptions.Enabled = false;
|
||||
|
||||
var worker = CreateWorker();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
await worker.StartAsync(cts.Token);
|
||||
await Task.Delay(50);
|
||||
await worker.StopAsync(cts.Token);
|
||||
|
||||
_queueMock.Verify(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Worker updates queue depth metrics")]
|
||||
public async Task ExecuteAsync_UpdatesQueueDepthMetrics()
|
||||
{
|
||||
_queueMock
|
||||
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new QueueDepthSnapshot(5, 2, 3, 1, DateTimeOffset.UtcNow));
|
||||
_queueMock
|
||||
.Setup(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var worker = CreateWorker();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
await worker.StartAsync(cts.Token);
|
||||
await Task.Delay(150);
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
_queueMock.Verify(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Worker processes items from queue")]
|
||||
public async Task ExecuteAsync_ProcessesItemsFromQueue()
|
||||
{
|
||||
var item = CreateTestItem();
|
||||
var items = new List<RekorQueueItem> { item };
|
||||
|
||||
_queueMock
|
||||
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new QueueDepthSnapshot(1, 0, 0, 0, DateTimeOffset.UtcNow));
|
||||
_queueMock
|
||||
.SetupSequence(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items)
|
||||
.ReturnsAsync([]);
|
||||
_rekorClientMock
|
||||
.Setup(r => r.SubmitAsync(It.IsAny<object>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RekorSubmissionResponse { Uuid = "test-uuid", Index = 12345 });
|
||||
|
||||
var worker = CreateWorker();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
await worker.StartAsync(cts.Token);
|
||||
await Task.Delay(200);
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
_queueMock.Verify(
|
||||
q => q.MarkSubmittedAsync(item.Id, "test-uuid", 12345, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Worker marks item for retry on failure")]
|
||||
public async Task ExecuteAsync_MarksRetryOnFailure()
|
||||
{
|
||||
var item = CreateTestItem();
|
||||
var items = new List<RekorQueueItem> { item };
|
||||
|
||||
_queueMock
|
||||
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new QueueDepthSnapshot(1, 0, 0, 0, DateTimeOffset.UtcNow));
|
||||
_queueMock
|
||||
.SetupSequence(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items)
|
||||
.ReturnsAsync([]);
|
||||
_rekorClientMock
|
||||
.Setup(r => r.SubmitAsync(It.IsAny<object>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Connection failed"));
|
||||
|
||||
var worker = CreateWorker();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
await worker.StartAsync(cts.Token);
|
||||
await Task.Delay(200);
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
_queueMock.Verify(
|
||||
q => q.MarkRetryAsync(item.Id, It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Worker marks dead letter after max attempts")]
|
||||
public async Task ExecuteAsync_MarksDeadLetterAfterMaxAttempts()
|
||||
{
|
||||
var item = CreateTestItem(attemptCount: 2); // Next attempt will be 3 (max)
|
||||
var items = new List<RekorQueueItem> { item };
|
||||
|
||||
_queueMock
|
||||
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new QueueDepthSnapshot(0, 0, 1, 0, DateTimeOffset.UtcNow));
|
||||
_queueMock
|
||||
.SetupSequence(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(items)
|
||||
.ReturnsAsync([]);
|
||||
_rekorClientMock
|
||||
.Setup(r => r.SubmitAsync(It.IsAny<object>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("Connection failed"));
|
||||
|
||||
var worker = CreateWorker();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
await worker.StartAsync(cts.Token);
|
||||
await Task.Delay(200);
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
|
||||
_queueMock.Verify(
|
||||
q => q.MarkDeadLetterAsync(item.Id, It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
private RekorRetryWorker CreateWorker()
|
||||
{
|
||||
return new RekorRetryWorker(
|
||||
_queueMock.Object,
|
||||
_rekorClientMock.Object,
|
||||
Options.Create(_queueOptions),
|
||||
Options.Create(_attestorOptions),
|
||||
_metrics,
|
||||
_timeProviderMock.Object,
|
||||
NullLogger<RekorRetryWorker>.Instance);
|
||||
}
|
||||
|
||||
private static RekorQueueItem CreateTestItem(int attemptCount = 0)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new RekorQueueItem(
|
||||
Guid.NewGuid(),
|
||||
"test-tenant",
|
||||
"sha256:abc123",
|
||||
new byte[] { 1, 2, 3 },
|
||||
"primary",
|
||||
RekorSubmissionStatus.Submitting,
|
||||
attemptCount,
|
||||
3,
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub response for tests.
|
||||
/// </summary>
|
||||
public sealed class RekorSubmissionResponse
|
||||
{
|
||||
public string? Uuid { get; init; }
|
||||
public long? Index { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// =============================================================================
|
||||
// RekorSubmissionQueueTests.cs
|
||||
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
|
||||
// Task: T13
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Queue;
|
||||
using StellaOps.Attestor.Infrastructure.Queue;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PostgresRekorSubmissionQueue.
|
||||
/// Note: Full integration tests require PostgreSQL via Testcontainers (Task T14).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "3000_0001_0002")]
|
||||
public sealed class RekorQueueOptionsTests
|
||||
{
|
||||
[Theory(DisplayName = "CalculateRetryDelay applies exponential backoff")]
|
||||
[InlineData(0, 1000)] // First retry: initial delay
|
||||
[InlineData(1, 2000)] // Second retry: 1000 * 2
|
||||
[InlineData(2, 4000)] // Third retry: 1000 * 2^2
|
||||
[InlineData(3, 8000)] // Fourth retry: 1000 * 2^3
|
||||
[InlineData(4, 16000)] // Fifth retry: 1000 * 2^4
|
||||
[InlineData(10, 60000)] // Many retries: capped at MaxDelayMs
|
||||
public void CalculateRetryDelay_AppliesExponentialBackoff(int attemptCount, int expectedMs)
|
||||
{
|
||||
var options = new RekorQueueOptions
|
||||
{
|
||||
InitialDelayMs = 1000,
|
||||
MaxDelayMs = 60000,
|
||||
BackoffMultiplier = 2.0
|
||||
};
|
||||
|
||||
var delay = options.CalculateRetryDelay(attemptCount);
|
||||
|
||||
delay.TotalMilliseconds.Should().Be(expectedMs);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Default options have sensible values")]
|
||||
public void DefaultOptions_HaveSensibleValues()
|
||||
{
|
||||
var options = new RekorQueueOptions();
|
||||
|
||||
options.Enabled.Should().BeTrue();
|
||||
options.MaxAttempts.Should().Be(5);
|
||||
options.InitialDelayMs.Should().Be(1000);
|
||||
options.MaxDelayMs.Should().Be(60000);
|
||||
options.BackoffMultiplier.Should().Be(2.0);
|
||||
options.BatchSize.Should().Be(10);
|
||||
options.PollIntervalMs.Should().Be(5000);
|
||||
options.DeadLetterRetentionDays.Should().Be(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for QueueDepthSnapshot.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "3000_0001_0002")]
|
||||
public sealed class QueueDepthSnapshotTests
|
||||
{
|
||||
[Fact(DisplayName = "TotalWaiting sums pending and retrying")]
|
||||
public void TotalWaiting_SumsPendingAndRetrying()
|
||||
{
|
||||
var snapshot = new QueueDepthSnapshot(10, 5, 3, 2, DateTimeOffset.UtcNow);
|
||||
|
||||
snapshot.TotalWaiting.Should().Be(13);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "TotalInQueue sums all non-submitted statuses")]
|
||||
public void TotalInQueue_SumsAllNonSubmitted()
|
||||
{
|
||||
var snapshot = new QueueDepthSnapshot(10, 5, 3, 2, DateTimeOffset.UtcNow);
|
||||
|
||||
snapshot.TotalInQueue.Should().Be(20);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Empty creates zero snapshot")]
|
||||
public void Empty_CreatesZeroSnapshot()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var snapshot = QueueDepthSnapshot.Empty(now);
|
||||
|
||||
snapshot.Pending.Should().Be(0);
|
||||
snapshot.Submitting.Should().Be(0);
|
||||
snapshot.Retrying.Should().Be(0);
|
||||
snapshot.DeadLetter.Should().Be(0);
|
||||
snapshot.MeasuredAt.Should().Be(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RekorQueueItem.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "3000_0001_0002")]
|
||||
public sealed class RekorQueueItemTests
|
||||
{
|
||||
[Fact(DisplayName = "RekorQueueItem properties are accessible")]
|
||||
public void RekorQueueItem_PropertiesAccessible()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var tenantId = "test-tenant";
|
||||
var bundleSha256 = "sha256:abc123";
|
||||
var dssePayload = new byte[] { 1, 2, 3 };
|
||||
var backend = "primary";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var item = new RekorQueueItem
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
BundleSha256 = bundleSha256,
|
||||
DssePayload = dssePayload,
|
||||
Backend = backend,
|
||||
Status = RekorSubmissionStatus.Pending,
|
||||
AttemptCount = 0,
|
||||
MaxAttempts = 5,
|
||||
NextRetryAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
item.Id.Should().Be(id);
|
||||
item.TenantId.Should().Be(tenantId);
|
||||
item.BundleSha256.Should().Be(bundleSha256);
|
||||
item.DssePayload.Should().BeEquivalentTo(dssePayload);
|
||||
item.Backend.Should().Be(backend);
|
||||
item.Status.Should().Be(RekorSubmissionStatus.Pending);
|
||||
item.AttemptCount.Should().Be(0);
|
||||
item.MaxAttempts.Should().Be(5);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RekorSubmissionStatus enum.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "3000_0001_0002")]
|
||||
public sealed class RekorSubmissionStatusTests
|
||||
{
|
||||
[Theory(DisplayName = "Status enum has expected values")]
|
||||
[InlineData(RekorSubmissionStatus.Pending, 0)]
|
||||
[InlineData(RekorSubmissionStatus.Submitting, 1)]
|
||||
[InlineData(RekorSubmissionStatus.Submitted, 2)]
|
||||
[InlineData(RekorSubmissionStatus.Retrying, 3)]
|
||||
[InlineData(RekorSubmissionStatus.DeadLetter, 4)]
|
||||
public void Status_HasExpectedValues(RekorSubmissionStatus status, int expectedValue)
|
||||
{
|
||||
((int)status).Should().Be(expectedValue);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user