feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- Add RateLimitConfig for configuration management with YAML binding support.
- Introduce RateLimitDecision to encapsulate the result of rate limit checks.
- Implement RateLimitMetrics for OpenTelemetry metrics tracking.
- Create RateLimitMiddleware for enforcing rate limits on incoming requests.
- Develop RateLimitService to orchestrate instance and environment rate limit checks.
- Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -0,0 +1,314 @@
// -----------------------------------------------------------------------------
// ProofsApiContractTests.cs
// Sprint: SPRINT_0501_0005_0001_proof_chain_api_surface
// Task: PROOF-API-0010 - API contract tests (OpenAPI validation)
// Description: Contract tests to verify API endpoints conform to OpenAPI spec
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Attestor.WebService.Contracts.Proofs;
using Xunit;
namespace StellaOps.Attestor.Tests.Api;
/// <summary>
/// API contract tests for /proofs/* endpoints.
/// Validates response shapes, status codes, and error formats per OpenAPI spec.
/// </summary>
public class ProofsApiContractTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProofsApiContractTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
#region POST /proofs/{entry}/spine Contract Tests
[Fact]
public async Task CreateSpine_ValidRequest_Returns201Created()
{
// Arrange
var entry = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1:pkg:npm/lodash@4.17.21";
var request = new CreateSpineRequest
{
EvidenceIds = new[] { "sha256:ev123abc456def789012345678901234567890123456789012345678901234" },
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
PolicyVersion = "v1.0.0"
};
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var content = await response.Content.ReadFromJsonAsync<CreateSpineResponse>();
Assert.NotNull(content);
Assert.NotEmpty(content.ProofBundleId);
Assert.Matches(@"^sha256:[a-f0-9]{64}$", content.ProofBundleId);
}
[Fact]
public async Task CreateSpine_InvalidEntryFormat_Returns400BadRequest()
{
// Arrange
var invalidEntry = "not-a-valid-entry";
var request = new CreateSpineRequest
{
EvidenceIds = new[] { "sha256:abc123" },
ReasoningId = "sha256:def456",
VexVerdictId = "sha256:789xyz",
PolicyVersion = "v1.0.0"
};
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{invalidEntry}/spine", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(problemDetails.TryGetProperty("title", out var title));
Assert.NotEmpty(title.GetString());
}
[Fact]
public async Task CreateSpine_MissingRequiredFields_Returns400BadRequest()
{
// Arrange
var entry = "sha256:abc123:pkg:npm/test@1.0.0";
var invalidRequest = new { }; // Missing all required fields
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", invalidRequest);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task CreateSpine_InvalidEvidenceIdFormat_Returns422UnprocessableEntity()
{
// Arrange
var entry = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1:pkg:npm/test@1.0.0";
var request = new CreateSpineRequest
{
EvidenceIds = new[] { "invalid-not-sha256" }, // Invalid format
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
PolicyVersion = "v1.0.0"
};
// Act
var response = await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", request);
// Assert - expect 400 or 422 for validation failure
Assert.True(
response.StatusCode == HttpStatusCode.BadRequest ||
response.StatusCode == HttpStatusCode.UnprocessableEntity);
}
#endregion
#region GET /proofs/{entry}/receipt Contract Tests
[Fact]
public async Task GetReceipt_ExistingEntry_Returns200WithReceipt()
{
// Arrange - first create a spine
var entry = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1:pkg:npm/test@1.0.0";
// Create spine first
var createRequest = new CreateSpineRequest
{
EvidenceIds = new[] { "sha256:ev123abc456def789012345678901234567890123456789012345678901234" },
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
PolicyVersion = "v1.0.0"
};
await _client.PostAsJsonAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", createRequest);
// Act
var response = await _client.GetAsync($"/proofs/{Uri.EscapeDataString(entry)}/receipt");
// Assert - may be 200 or 404 depending on implementation state
Assert.True(
response.StatusCode == HttpStatusCode.OK ||
response.StatusCode == HttpStatusCode.NotFound,
$"Expected 200 OK or 404 Not Found, got {response.StatusCode}");
if (response.StatusCode == HttpStatusCode.OK)
{
var receipt = await response.Content.ReadFromJsonAsync<VerificationReceiptDto>();
Assert.NotNull(receipt);
Assert.NotEmpty(receipt.ProofBundleId);
Assert.NotNull(receipt.VerifiedAt);
Assert.NotEmpty(receipt.Result);
Assert.Contains(receipt.Result, new[] { "pass", "fail" });
}
}
[Fact]
public async Task GetReceipt_NonExistentEntry_Returns404NotFound()
{
// Arrange
var nonExistentEntry = "sha256:nonexistent123456789012345678901234567890123456789012345678901:pkg:npm/ghost@0.0.0";
// Act
var response = await _client.GetAsync($"/proofs/{Uri.EscapeDataString(nonExistentEntry)}/receipt");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problemDetails = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(problemDetails.TryGetProperty("status", out var status));
Assert.Equal(404, status.GetInt32());
}
#endregion
#region Response Format Contract Tests
[Fact]
public async Task AllEndpoints_ReturnJsonContentType()
{
// Arrange
var entry = "sha256:test123:pkg:npm/test@1.0.0";
// Act
var getResponse = await _client.GetAsync($"/proofs/{Uri.EscapeDataString(entry)}/receipt");
// Assert
Assert.Contains("application/json", getResponse.Content.Headers.ContentType?.MediaType ?? "");
}
[Fact]
public async Task ErrorResponses_UseProblemDetailsFormat()
{
// Arrange
var invalidEntry = "invalid";
// Act
var response = await _client.GetAsync($"/proofs/{invalidEntry}/receipt");
// Assert - check problem details structure
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(content))
{
var json = JsonDocument.Parse(content);
// Problem Details should have these fields (RFC 7807)
var root = json.RootElement;
// At minimum should have status or title
Assert.True(
root.TryGetProperty("status", out _) ||
root.TryGetProperty("title", out _) ||
root.TryGetProperty("type", out _),
"Error response should follow Problem Details format");
}
}
}
#endregion
#region Content Negotiation Tests
[Fact]
public async Task Endpoint_AcceptsJsonContentType()
{
// Arrange
var entry = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abc1:pkg:npm/test@1.0.0";
var request = new CreateSpineRequest
{
EvidenceIds = new[] { "sha256:ev123abc456def789012345678901234567890123456789012345678901234" },
ReasoningId = "sha256:reason123abc456def789012345678901234567890123456789012345678901",
VexVerdictId = "sha256:vex123abc456def789012345678901234567890123456789012345678901234",
PolicyVersion = "v1.0.0"
};
var jsonContent = new StringContent(
JsonSerializer.Serialize(request),
System.Text.Encoding.UTF8,
"application/json");
// Act
var response = await _client.PostAsync($"/proofs/{Uri.EscapeDataString(entry)}/spine", jsonContent);
// Assert - should accept JSON
Assert.NotEqual(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
}
#endregion
}
/// <summary>
/// Contract tests for /anchors/* endpoints.
/// </summary>
public class AnchorsApiContractTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public AnchorsApiContractTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetAnchor_NonExistentId_Returns404()
{
// Arrange
var nonExistentId = Guid.NewGuid();
// Act
var response = await _client.GetAsync($"/anchors/{nonExistentId}");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetAnchor_InvalidIdFormat_Returns400()
{
// Arrange
var invalidId = "not-a-guid";
// Act
var response = await _client.GetAsync($"/anchors/{invalidId}");
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
/// <summary>
/// Contract tests for /verify/* endpoints.
/// </summary>
public class VerifyApiContractTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public VerifyApiContractTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task VerifyBundle_InvalidBundleId_Returns400()
{
// Arrange
var invalidBundleId = "invalid";
// Act
var response = await _client.PostAsync($"/verify/{invalidBundleId}", null);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View File

@@ -0,0 +1,399 @@
// -----------------------------------------------------------------------------
// PostgresRekorSubmissionQueueIntegrationTests.cs
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
// Task: T14
// Description: PostgreSQL integration tests for Rekor submission queue
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Queue;
using StellaOps.Attestor.Infrastructure.Queue;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Attestor.Tests.Integration.Queue;
/// <summary>
/// Integration tests for PostgresRekorSubmissionQueue using Testcontainers.
/// These tests verify end-to-end queue operations against a real PostgreSQL instance.
/// </summary>
[Trait("Category", "Integration")]
public class PostgresRekorSubmissionQueueIntegrationTests : IAsyncLifetime
{
private PostgreSqlContainer _postgres = null!;
private NpgsqlDataSource _dataSource = null!;
private PostgresRekorSubmissionQueue _queue = null!;
private FakeTimeProvider _timeProvider = null!;
private AttestorMetrics _metrics = null!;
public async Task InitializeAsync()
{
_postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("stellaops_attestor")
.WithUsername("test")
.WithPassword("test")
.Build();
await _postgres.StartAsync();
var connectionString = _postgres.GetConnectionString();
_dataSource = NpgsqlDataSource.Create(connectionString);
// Create the schema and table
await CreateSchemaAndTableAsync();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 17, 12, 0, 0, TimeSpan.Zero));
_metrics = new AttestorMetrics(new System.Diagnostics.Metrics.Meter("test"));
_queue = new PostgresRekorSubmissionQueue(
_dataSource,
Options.Create(new RekorQueueOptions
{
MaxAttempts = 5,
RetryDelaySeconds = 60,
BatchSize = 10
}),
_metrics,
_timeProvider,
NullLogger<PostgresRekorSubmissionQueue>.Instance);
}
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
await _postgres.DisposeAsync();
}
private async Task CreateSchemaAndTableAsync()
{
const string schemaAndTableSql = """
CREATE SCHEMA IF NOT EXISTS attestor;
CREATE TABLE IF NOT EXISTS attestor.rekor_submission_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
bundle_sha256 TEXT NOT NULL,
dsse_payload BYTEA NOT NULL,
backend TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
last_attempt_at TIMESTAMPTZ,
last_error TEXT,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rekor_queue_status_retry
ON attestor.rekor_submission_queue (status, next_retry_at)
WHERE status IN ('pending', 'retrying');
CREATE INDEX IF NOT EXISTS idx_rekor_queue_tenant
ON attestor.rekor_submission_queue (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rekor_queue_bundle
ON attestor.rekor_submission_queue (bundle_sha256);
""";
await using var connection = await _dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(schemaAndTableSql, connection);
await command.ExecuteNonQueryAsync();
}
#region Enqueue Tests
[Fact]
public async Task EnqueueAsync_ValidItem_InsertsIntoDatabase()
{
// Arrange
var tenantId = "tenant-123";
var bundleSha256 = "sha256:abc123";
var dssePayload = new byte[] { 0x01, 0x02, 0x03 };
var backend = "primary";
// Act
var id = await _queue.EnqueueAsync(tenantId, bundleSha256, dssePayload, backend);
// Assert
id.Should().NotBeEmpty();
var item = await GetQueueItemByIdAsync(id);
item.Should().NotBeNull();
item!.TenantId.Should().Be(tenantId);
item.BundleSha256.Should().Be(bundleSha256);
item.Status.Should().Be(RekorSubmissionStatus.Pending);
item.AttemptCount.Should().Be(0);
}
[Fact]
public async Task EnqueueAsync_MultipleItems_AllInserted()
{
// Arrange & Act
var ids = new List<Guid>();
for (int i = 0; i < 5; i++)
{
ids.Add(await _queue.EnqueueAsync(
$"tenant-{i}",
$"sha256:bundle{i}",
new byte[] { (byte)i },
"primary"));
}
// Assert
var count = await GetQueueCountAsync();
count.Should().BeGreaterOrEqualTo(5);
}
#endregion
#region Dequeue Tests
[Fact]
public async Task DequeueAsync_PendingItems_ReturnsAndMarksSubmitting()
{
// Arrange
await _queue.EnqueueAsync("tenant-1", "sha256:bundle1", new byte[] { 0x01 }, "primary");
await _queue.EnqueueAsync("tenant-2", "sha256:bundle2", new byte[] { 0x02 }, "primary");
// Act
var items = await _queue.DequeueAsync(10);
// Assert
items.Should().HaveCountGreaterOrEqualTo(2);
items.Should().OnlyContain(i => i.Status == RekorSubmissionStatus.Submitting);
}
[Fact]
public async Task DequeueAsync_EmptyQueue_ReturnsEmpty()
{
// Act
var items = await _queue.DequeueAsync(10);
// Assert - may have items from other tests but status should filter them
items.Where(i => i.Status == RekorSubmissionStatus.Pending).Should().BeEmpty();
}
[Fact]
public async Task DequeueAsync_BatchSize_RespectsLimit()
{
// Arrange
for (int i = 0; i < 10; i++)
{
await _queue.EnqueueAsync($"tenant-batch-{i}", $"sha256:batch{i}", new byte[] { (byte)i }, "primary");
}
// Act
var items = await _queue.DequeueAsync(3);
// Assert
items.Should().HaveCountLessOrEqualTo(3);
}
[Fact]
public async Task DequeueAsync_ConcurrentSafe_NoDoubleDequeue()
{
// Arrange
var uniqueBundle = $"sha256:concurrent-{Guid.NewGuid()}";
await _queue.EnqueueAsync("tenant-concurrent", uniqueBundle, new byte[] { 0x01 }, "primary");
// Act - Simulate concurrent dequeue
var task1 = _queue.DequeueAsync(10);
var task2 = _queue.DequeueAsync(10);
var results = await Task.WhenAll(task1, task2);
// Assert - Item should only appear in one result
var allItems = results.SelectMany(r => r).Where(i => i.BundleSha256 == uniqueBundle).ToList();
allItems.Should().HaveCountLessOrEqualTo(1);
}
#endregion
#region Status Update Tests
[Fact]
public async Task MarkSubmittedAsync_UpdatesStatusAndLogIndex()
{
// Arrange
var id = await _queue.EnqueueAsync("tenant-1", "sha256:submit", new byte[] { 0x01 }, "primary");
await _queue.DequeueAsync(10); // Move to submitting
// Act
await _queue.MarkSubmittedAsync(id, 12345L);
// Assert
var item = await GetQueueItemByIdAsync(id);
item!.Status.Should().Be(RekorSubmissionStatus.Submitted);
}
[Fact]
public async Task MarkFailedAsync_SchedulesRetry()
{
// Arrange
var id = await _queue.EnqueueAsync("tenant-1", "sha256:fail", new byte[] { 0x01 }, "primary");
await _queue.DequeueAsync(10); // Move to submitting
// Act
await _queue.MarkFailedAsync(id, "Connection refused");
// Assert
var item = await GetQueueItemByIdAsync(id);
item!.Status.Should().Be(RekorSubmissionStatus.Retrying);
item.LastError.Should().Be("Connection refused");
item.AttemptCount.Should().Be(1);
}
[Fact]
public async Task MarkFailedAsync_MaxAttempts_MovesToDeadLetter()
{
// Arrange - Use custom options with low max attempts
var queue = new PostgresRekorSubmissionQueue(
_dataSource,
Options.Create(new RekorQueueOptions { MaxAttempts = 2 }),
_metrics,
_timeProvider,
NullLogger<PostgresRekorSubmissionQueue>.Instance);
var id = await queue.EnqueueAsync("tenant-1", "sha256:deadletter", new byte[] { 0x01 }, "primary");
// Fail twice
await queue.DequeueAsync(10);
await queue.MarkFailedAsync(id, "Attempt 1");
_timeProvider.Advance(TimeSpan.FromMinutes(5));
await queue.DequeueAsync(10);
await queue.MarkFailedAsync(id, "Attempt 2");
// Assert
var item = await GetQueueItemByIdAsync(id);
item!.Status.Should().Be(RekorSubmissionStatus.DeadLetter);
}
#endregion
#region Queue Depth Tests
[Fact]
public async Task GetQueueDepthAsync_ReturnsCorrectCount()
{
// Arrange
var baseDepth = await _queue.GetQueueDepthAsync();
await _queue.EnqueueAsync("tenant-depth-1", "sha256:depth1", new byte[] { 0x01 }, "primary");
await _queue.EnqueueAsync("tenant-depth-2", "sha256:depth2", new byte[] { 0x02 }, "primary");
// Act
var newDepth = await _queue.GetQueueDepthAsync();
// Assert
newDepth.Should().BeGreaterOrEqualTo(baseDepth + 2);
}
[Fact]
public async Task GetDeadLetterCountAsync_ReturnsCorrectCount()
{
// Arrange
var queue = new PostgresRekorSubmissionQueue(
_dataSource,
Options.Create(new RekorQueueOptions { MaxAttempts = 1 }),
_metrics,
_timeProvider,
NullLogger<PostgresRekorSubmissionQueue>.Instance);
var id = await queue.EnqueueAsync("tenant-dlq", "sha256:dlq", new byte[] { 0x01 }, "primary");
await queue.DequeueAsync(10);
await queue.MarkFailedAsync(id, "Fail");
// Act
var dlqCount = await queue.GetDeadLetterCountAsync();
// Assert
dlqCount.Should().BeGreaterOrEqualTo(1);
}
#endregion
#region Helper Methods
private async Task<RekorQueueItem?> GetQueueItemByIdAsync(Guid id)
{
const string sql = """
SELECT id, tenant_id, bundle_sha256, dsse_payload, backend,
status, attempt_count, max_attempts, next_retry_at,
created_at, updated_at, last_error
FROM attestor.rekor_submission_queue
WHERE id = @id
""";
await using var connection = await _dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("@id", id);
await using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
return new RekorQueueItem
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
BundleSha256 = reader.GetString(reader.GetOrdinal("bundle_sha256")),
DssePayload = reader.GetFieldValue<byte[]>(reader.GetOrdinal("dsse_payload")),
Backend = reader.GetString(reader.GetOrdinal("backend")),
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
AttemptCount = reader.GetInt32(reader.GetOrdinal("attempt_count")),
LastError = reader.IsDBNull(reader.GetOrdinal("last_error"))
? null
: reader.GetString(reader.GetOrdinal("last_error"))
};
}
return null;
}
private async Task<int> GetQueueCountAsync()
{
const string sql = "SELECT COUNT(*) FROM attestor.rekor_submission_queue";
await using var connection = await _dataSource.OpenConnectionAsync();
await using var command = new NpgsqlCommand(sql, connection);
return Convert.ToInt32(await command.ExecuteScalarAsync());
}
private static RekorSubmissionStatus ParseStatus(string status) => status.ToLowerInvariant() switch
{
"pending" => RekorSubmissionStatus.Pending,
"submitting" => RekorSubmissionStatus.Submitting,
"submitted" => RekorSubmissionStatus.Submitted,
"retrying" => RekorSubmissionStatus.Retrying,
"dead_letter" => RekorSubmissionStatus.DeadLetter,
_ => throw new ArgumentException($"Unknown status: {status}")
};
#endregion
}
/// <summary>
/// Fake time provider for testing.
/// </summary>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset initialTime)
{
_now = initialTime;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetTime(DateTimeOffset time) => _now = time;
}

View File

@@ -9,8 +9,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="Testcontainers" Version="4.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />

View File

@@ -0,0 +1,589 @@
// -----------------------------------------------------------------------------
// TimeSkewValidationIntegrationTests.cs
// Sprint: SPRINT_3000_0001_0003_rekor_time_skew_validation
// Task: T10
// Description: Integration tests for time skew validation in submission and verification services
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Infrastructure.Verification;
using StellaOps.Attestor.Tests.Support;
using StellaOps.Attestor.Verify;
using Xunit;
namespace StellaOps.Attestor.Tests;
/// <summary>
/// Integration tests for time skew validation in submission and verification services.
/// Per SPRINT_3000_0001_0003 - T10: Add integration coverage.
/// </summary>
public sealed class TimeSkewValidationIntegrationTests : IDisposable
{
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
private readonly AttestorMetrics _metrics;
private readonly AttestorActivitySource _activitySource;
private readonly DefaultDsseCanonicalizer _canonicalizer;
private readonly InMemoryAttestorEntryRepository _repository;
private readonly InMemoryAttestorDedupeStore _dedupeStore;
private readonly InMemoryAttestorAuditSink _auditSink;
private readonly NullAttestorArchiveStore _archiveStore;
private readonly NullTransparencyWitnessClient _witnessClient;
private readonly NullVerificationCache _verificationCache;
private bool _disposed;
public TimeSkewValidationIntegrationTests()
{
_metrics = new AttestorMetrics();
_activitySource = new AttestorActivitySource();
_canonicalizer = new DefaultDsseCanonicalizer();
_repository = new InMemoryAttestorEntryRepository();
_dedupeStore = new InMemoryAttestorDedupeStore();
_auditSink = new InMemoryAttestorAuditSink();
_archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
_witnessClient = new NullTransparencyWitnessClient();
_verificationCache = new NullVerificationCache();
}
public void Dispose()
{
if (!_disposed)
{
_metrics.Dispose();
_activitySource.Dispose();
_disposed = true;
}
}
#region Submission Integration Tests
[Fact]
public async Task Submission_WithTimeSkewBeyondRejectThreshold_ThrowsTimeSkewValidationException_WhenFailOnRejectEnabled()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true,
WarnThresholdSeconds = 60,
RejectThresholdSeconds = 300,
FailOnReject = true
};
var options = CreateAttestorOptions(timeSkewOptions);
// Create a Rekor client that returns an integrated time way in the past
var pastTime = DateTimeOffset.UtcNow.AddSeconds(-600); // 10 minutes ago
var rekorClient = new ConfigurableTimeRekorClient(pastTime);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
// Act & Assert
await Assert.ThrowsAsync<TimeSkewValidationException>(async () =>
{
await submissionService.SubmitAsync(request, context);
});
}
[Fact]
public async Task Submission_WithTimeSkewBeyondRejectThreshold_Succeeds_WhenFailOnRejectDisabled()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true,
WarnThresholdSeconds = 60,
RejectThresholdSeconds = 300,
FailOnReject = false // Disabled - should log but not fail
};
var options = CreateAttestorOptions(timeSkewOptions);
// Create a Rekor client that returns an integrated time way in the past
var pastTime = DateTimeOffset.UtcNow.AddSeconds(-600); // 10 minutes ago
var rekorClient = new ConfigurableTimeRekorClient(pastTime);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
// Act
var result = await submissionService.SubmitAsync(request, context);
// Assert - should succeed but emit metrics
Assert.NotNull(result);
Assert.NotNull(result.Uuid);
}
[Fact]
public async Task Submission_WithTimeSkewBelowWarnThreshold_Succeeds()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true,
WarnThresholdSeconds = 60,
RejectThresholdSeconds = 300,
FailOnReject = true
};
var options = CreateAttestorOptions(timeSkewOptions);
// Create a Rekor client that returns an integrated time just a few seconds ago
var recentTime = DateTimeOffset.UtcNow.AddSeconds(-10); // 10 seconds ago
var rekorClient = new ConfigurableTimeRekorClient(recentTime);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
// Act
var result = await submissionService.SubmitAsync(request, context);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Uuid);
}
[Fact]
public async Task Submission_WithFutureTimestamp_ThrowsTimeSkewValidationException()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true,
MaxFutureSkewSeconds = 60,
FailOnReject = true
};
var options = CreateAttestorOptions(timeSkewOptions);
// Create a Rekor client that returns a future integrated time
var futureTime = DateTimeOffset.UtcNow.AddSeconds(120); // 2 minutes in the future
var rekorClient = new ConfigurableTimeRekorClient(futureTime);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
// Act & Assert
await Assert.ThrowsAsync<TimeSkewValidationException>(async () =>
{
await submissionService.SubmitAsync(request, context);
});
}
[Fact]
public async Task Submission_WhenValidationDisabled_SkipsTimeSkewCheck()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = false // Disabled
};
var options = CreateAttestorOptions(timeSkewOptions);
// Create a Rekor client with a very old integrated time
var veryOldTime = DateTimeOffset.UtcNow.AddHours(-24);
var rekorClient = new ConfigurableTimeRekorClient(veryOldTime);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submissionService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
// Act - should succeed even with very old timestamp because validation is disabled
var result = await submissionService.SubmitAsync(request, context);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Uuid);
}
#endregion
#region Verification Integration Tests
[Fact]
public async Task Verification_WithTimeSkewBeyondRejectThreshold_IncludesIssueInReport_WhenFailOnRejectEnabled()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true,
WarnThresholdSeconds = 60,
RejectThresholdSeconds = 300,
FailOnReject = true
};
var options = CreateAttestorOptions(timeSkewOptions);
// First, submit with normal time
var submitRekorClient = new ConfigurableTimeRekorClient(DateTimeOffset.UtcNow);
var submitTimeSkewValidator = new TimeSkewValidator(new TimeSkewOptions { Enabled = false }); // Disable for submission
var submitService = CreateSubmissionService(options, submitRekorClient, submitTimeSkewValidator);
var (request, context) = CreateSubmissionRequest();
var submissionResult = await submitService.SubmitAsync(request, context);
// Now manually update the entry with an old integrated time for verification testing
var entry = await _repository.GetByUuidAsync(submissionResult.Uuid);
Assert.NotNull(entry);
// Create a new entry with old integrated time
var oldIntegratedTime = DateTimeOffset.UtcNow.AddSeconds(-600); // 10 minutes ago
var updatedEntry = entry with
{
Log = entry.Log with
{
IntegratedTimeUtc = oldIntegratedTime
}
};
await _repository.SaveAsync(updatedEntry);
// Create verification service with time skew validation enabled
var verifyTimeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var verificationService = CreateVerificationService(options, rekorClient, verifyTimeSkewValidator);
// Act
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = submissionResult.Uuid,
Bundle = request.Bundle
});
// Assert
Assert.False(verifyResult.Ok);
Assert.Contains(verifyResult.Issues, i => i.Contains("time_skew"));
}
[Fact]
public async Task Verification_WithTimeSkewBelowThreshold_PassesValidation()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true,
WarnThresholdSeconds = 60,
RejectThresholdSeconds = 300,
FailOnReject = true
};
var options = CreateAttestorOptions(timeSkewOptions);
// Submit with recent integrated time
var recentTime = DateTimeOffset.UtcNow.AddSeconds(-5);
var rekorClient = new ConfigurableTimeRekorClient(recentTime);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submitService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
var submissionResult = await submitService.SubmitAsync(request, context);
// Verify
var verifyRekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var verificationService = CreateVerificationService(options, verifyRekorClient, timeSkewValidator);
// Act
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = submissionResult.Uuid,
Bundle = request.Bundle
});
// Assert - should pass (no time skew issue)
// Note: Other issues may exist (e.g., witness_missing) but not time_skew
Assert.DoesNotContain(verifyResult.Issues, i => i.Contains("time_skew_rejected"));
}
[Fact]
public async Task Verification_OfflineMode_SkipsTimeSkewValidation()
{
// Arrange
var timeSkewOptions = new TimeSkewOptions
{
Enabled = true, // Enabled, but should be skipped in offline mode due to missing integrated time
WarnThresholdSeconds = 60,
RejectThresholdSeconds = 300,
FailOnReject = true
};
var options = CreateAttestorOptions(timeSkewOptions);
// Submit without integrated time (simulates offline stored entry)
var rekorClient = new ConfigurableTimeRekorClient(integratedTime: null);
var timeSkewValidator = new InstrumentedTimeSkewValidator(
timeSkewOptions,
_metrics,
new NullLogger<InstrumentedTimeSkewValidator>());
var submitService = CreateSubmissionService(options, rekorClient, timeSkewValidator);
var (request, context) = CreateSubmissionRequest();
var submissionResult = await submitService.SubmitAsync(request, context);
// Verify
var verifyRekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var verificationService = CreateVerificationService(options, verifyRekorClient, timeSkewValidator);
// Act
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
{
Uuid = submissionResult.Uuid,
Bundle = request.Bundle
});
// Assert - should not have time skew issues (skipped due to missing integrated time)
Assert.DoesNotContain(verifyResult.Issues, i => i.Contains("time_skew_rejected"));
}
#endregion
#region Metrics Integration Tests
[Fact]
public void TimeSkewMetrics_AreRegistered()
{
// Assert - metrics should be created
Assert.NotNull(_metrics.TimeSkewDetectedTotal);
Assert.NotNull(_metrics.TimeSkewSeconds);
}
#endregion
#region Helper Methods
private IOptions<AttestorOptions> CreateAttestorOptions(TimeSkewOptions timeSkewOptions)
{
return Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
},
Security = new AttestorOptions.SecurityOptions
{
SignerIdentity = new AttestorOptions.SignerIdentityOptions
{
Mode = { "kms" },
KmsKeys = { HmacSecretBase64 }
}
},
TimeSkew = timeSkewOptions
});
}
private AttestorSubmissionService CreateSubmissionService(
IOptions<AttestorOptions> options,
IRekorClient rekorClient,
ITimeSkewValidator timeSkewValidator)
{
return new AttestorSubmissionService(
new AttestorSubmissionValidator(_canonicalizer),
_repository,
_dedupeStore,
rekorClient,
_witnessClient,
_archiveStore,
_auditSink,
_verificationCache,
timeSkewValidator,
options,
new NullLogger<AttestorSubmissionService>(),
TimeProvider.System,
_metrics);
}
private AttestorVerificationService CreateVerificationService(
IOptions<AttestorOptions> options,
IRekorClient rekorClient,
ITimeSkewValidator timeSkewValidator)
{
var engine = new AttestorVerificationEngine(
_canonicalizer,
new TestCryptoHash(),
options,
new NullLogger<AttestorVerificationEngine>());
return new AttestorVerificationService(
_repository,
_canonicalizer,
rekorClient,
_witnessClient,
engine,
timeSkewValidator,
options,
new NullLogger<AttestorVerificationService>(),
_metrics,
_activitySource,
TimeProvider.System);
}
private (AttestorSubmissionRequest Request, SubmissionContext Context) CreateSubmissionRequest()
{
var artifactSha256 = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
var payloadType = "application/vnd.in-toto+json";
var payloadJson = $$$"""{"_type":"https://in-toto.io/Statement/v0.1","subject":[{"name":"test","digest":{"sha256":"{{{artifactSha256}}}"}}],"predicateType":"https://slsa.dev/provenance/v1","predicate":{}}""";
var payload = Encoding.UTF8.GetBytes(payloadJson);
var payloadBase64 = Convert.ToBase64String(payload);
// Create HMAC signature
using var hmac = new HMACSHA256(HmacSecret);
var signature = hmac.ComputeHash(payload);
var signatureBase64 = Convert.ToBase64String(signature);
var bundle = new DsseBundle
{
Mode = "kms",
PayloadType = payloadType,
Payload = payloadBase64,
Signatures =
[
new DsseSignature
{
KeyId = "kms-key-1",
Sig = signatureBase64
}
]
};
var bundleBytes = _canonicalizer.Canonicalize(bundle);
var bundleSha256 = Convert.ToHexStringLower(SHA256.HashData(bundleBytes));
var request = new AttestorSubmissionRequest
{
Bundle = bundle,
Meta = new AttestorSubmissionRequest.MetaData
{
BundleSha256 = bundleSha256,
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = artifactSha256,
Kind = "container",
ImageDigest = $"sha256:{artifactSha256}"
},
LogPreference = "primary"
}
};
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
return (request, context);
}
#endregion
#region Test Doubles
/// <summary>
/// A Rekor client that returns configurable integrated times.
/// </summary>
private sealed class ConfigurableTimeRekorClient : IRekorClient
{
private readonly DateTimeOffset? _integratedTime;
private int _callCount;
public ConfigurableTimeRekorClient(DateTimeOffset? integratedTime)
{
_integratedTime = integratedTime;
}
public Task<RekorSubmissionResponse> SubmitAsync(
RekorSubmissionRequest request,
string url,
CancellationToken cancellationToken = default)
{
var uuid = Guid.NewGuid().ToString("N");
var index = Interlocked.Increment(ref _callCount);
return Task.FromResult(new RekorSubmissionResponse
{
Uuid = uuid,
Index = index,
LogUrl = url,
Status = "included",
IntegratedTimeUtc = _integratedTime
});
}
public Task<RekorProofResponse?> GetProofAsync(
string uuid,
string url,
CancellationToken cancellationToken = default)
{
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
{
TreeId = "test-tree-id",
LogIndex = 1,
TreeSize = 100,
RootHash = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
Hashes = [Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))]
});
}
public Task<RekorEntryResponse?> GetEntryAsync(
string uuid,
string url,
CancellationToken cancellationToken = default)
{
return Task.FromResult<RekorEntryResponse?>(null);
}
}
#endregion
}