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:
master
2025-12-16 18:44:25 +02:00
parent 2170a58734
commit 3a2100aa78
126 changed files with 15776 additions and 542 deletions

View File

@@ -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; }
}

View File

@@ -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);
}
}