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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user