- 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.
353 lines
12 KiB
C#
353 lines
12 KiB
C#
// -----------------------------------------------------------------------------
|
|
// KeyRotationWorkflowIntegrationTests.cs
|
|
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
|
|
// Task: PROOF-KEY-0013 - Integration tests for rotation workflow
|
|
// Description: End-to-end integration tests for the full key rotation workflow
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Threading.Tasks;
|
|
|
|
using FluentAssertions;
|
|
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
using StellaOps.Signer.KeyManagement;
|
|
using StellaOps.Signer.KeyManagement.Entities;
|
|
using StellaOps.Signer.WebService.Endpoints;
|
|
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Signer.Tests.Integration;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the complete key rotation workflow.
|
|
/// Tests the full lifecycle: add key → transition period → revoke old key.
|
|
/// </summary>
|
|
public class KeyRotationWorkflowIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
|
{
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
private readonly HttpClient _client;
|
|
private Guid _testAnchorId;
|
|
|
|
public KeyRotationWorkflowIntegrationTests(WebApplicationFactory<Program> factory)
|
|
{
|
|
_factory = factory.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
// Use in-memory database for tests
|
|
var descriptor = services.SingleOrDefault(
|
|
d => d.ServiceType == typeof(DbContextOptions<KeyManagementDbContext>));
|
|
if (descriptor != null)
|
|
{
|
|
services.Remove(descriptor);
|
|
}
|
|
|
|
services.AddDbContext<KeyManagementDbContext>(options =>
|
|
{
|
|
options.UseInMemoryDatabase($"IntegrationTestDb_{Guid.NewGuid()}");
|
|
});
|
|
});
|
|
});
|
|
|
|
_client = _factory.CreateClient();
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
// Create a test trust anchor
|
|
using var scope = _factory.Services.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<KeyManagementDbContext>();
|
|
|
|
_testAnchorId = Guid.NewGuid();
|
|
var anchor = new TrustAnchorEntity
|
|
{
|
|
Id = _testAnchorId,
|
|
PurlPattern = "pkg:npm/*",
|
|
AllowedKeyIds = ["initial-key"],
|
|
RevokedKeyIds = [],
|
|
PolicyVersion = "v1.0.0",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
dbContext.TrustAnchors.Add(anchor);
|
|
dbContext.KeyHistories.Add(new KeyHistoryEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TrustAnchorId = _testAnchorId,
|
|
KeyId = "initial-key",
|
|
Algorithm = "Ed25519",
|
|
AddedAt = DateTimeOffset.UtcNow.AddMonths(-6),
|
|
CreatedBy = "system"
|
|
});
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
#region Full Rotation Workflow Tests
|
|
|
|
[Fact]
|
|
public async Task FullRotationWorkflow_AddNewKey_TransitionPeriod_RevokeOldKey()
|
|
{
|
|
// Step 1: Add new key (begin transition period)
|
|
var addKeyRequest = new AddKeyRequestDto
|
|
{
|
|
KeyId = "new-key-2025",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "Ed25519"
|
|
};
|
|
|
|
var addResponse = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys",
|
|
addKeyRequest);
|
|
|
|
addResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
var addResult = await addResponse.Content.ReadFromJsonAsync<AddKeyResponseDto>();
|
|
addResult!.AllowedKeyIds.Should().Contain("initial-key");
|
|
addResult.AllowedKeyIds.Should().Contain("new-key-2025");
|
|
|
|
// Step 2: Verify both keys are valid during transition period
|
|
var validity1 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/validity?signedAt={DateTimeOffset.UtcNow:O}");
|
|
var validity2 = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/new-key-2025/validity?signedAt={DateTimeOffset.UtcNow:O}");
|
|
|
|
validity1!.IsValid.Should().BeTrue();
|
|
validity2!.IsValid.Should().BeTrue();
|
|
|
|
// Step 3: Revoke old key
|
|
var revokeRequest = new RevokeKeyRequestDto
|
|
{
|
|
Reason = "rotation-complete"
|
|
};
|
|
|
|
var revokeResponse = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/initial-key/revoke",
|
|
revokeRequest);
|
|
|
|
revokeResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
var revokeResult = await revokeResponse.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
|
|
revokeResult!.AllowedKeyIds.Should().NotContain("initial-key");
|
|
revokeResult.AllowedKeyIds.Should().Contain("new-key-2025");
|
|
revokeResult.RevokedKeyIds.Should().Contain("initial-key");
|
|
|
|
// Step 4: Verify key history is complete
|
|
var history = await _client.GetFromJsonAsync<KeyHistoryResponseDto>(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/history");
|
|
|
|
history!.Entries.Should().HaveCount(2);
|
|
|
|
var oldKeyEntry = history.Entries.First(e => e.KeyId == "initial-key");
|
|
oldKeyEntry.RevokedAt.Should().NotBeNull();
|
|
oldKeyEntry.RevokeReason.Should().Be("rotation-complete");
|
|
|
|
var newKeyEntry = history.Entries.First(e => e.KeyId == "new-key-2025");
|
|
newKeyEntry.RevokedAt.Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoricalProofVerification_SignedBeforeRevocation_RemainsValid()
|
|
{
|
|
// Arrange: add and revoke a key
|
|
var addRequest = new AddKeyRequestDto
|
|
{
|
|
KeyId = "old-key",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "Ed25519"
|
|
};
|
|
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
|
|
|
|
// Record time before revocation
|
|
var signedBeforeRevocation = DateTimeOffset.UtcNow;
|
|
|
|
// Revoke the key
|
|
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
|
|
await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/revoke",
|
|
revokeRequest);
|
|
|
|
// Act: check validity at time before revocation
|
|
var validity = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/old-key/validity?signedAt={signedBeforeRevocation:O}");
|
|
|
|
// Assert: key should be valid for proofs signed before revocation
|
|
validity!.IsValid.Should().BeTrue("proofs signed before revocation should remain valid");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HistoricalProofVerification_SignedAfterRevocation_IsInvalid()
|
|
{
|
|
// Arrange: add a key, then revoke it
|
|
var addRequest = new AddKeyRequestDto
|
|
{
|
|
KeyId = "revoked-key",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "Ed25519"
|
|
};
|
|
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
|
|
|
|
var revokeRequest = new RevokeKeyRequestDto { Reason = "test-revocation" };
|
|
await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/revoke",
|
|
revokeRequest);
|
|
|
|
// Act: check validity at time after revocation
|
|
var signedAfterRevocation = DateTimeOffset.UtcNow.AddMinutes(5);
|
|
var validity = await _client.GetFromJsonAsync<KeyValidityResponseDto>(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/revoked-key/validity?signedAt={signedAfterRevocation:O}");
|
|
|
|
// Assert: key should be invalid for proofs signed after revocation
|
|
validity!.IsValid.Should().BeFalse("proofs signed after revocation should be invalid");
|
|
validity.Status.Should().Be("Revoked");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Audit Trail Tests
|
|
|
|
[Fact]
|
|
public async Task AddKey_CreatesAuditLogEntry()
|
|
{
|
|
// Arrange
|
|
var request = new AddKeyRequestDto
|
|
{
|
|
KeyId = "audited-key",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "Ed25519"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys",
|
|
request);
|
|
|
|
// Assert
|
|
var result = await response.Content.ReadFromJsonAsync<AddKeyResponseDto>();
|
|
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeKey_CreatesAuditLogEntry()
|
|
{
|
|
// Arrange: first add a key
|
|
var addRequest = new AddKeyRequestDto
|
|
{
|
|
KeyId = "key-to-revoke",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "Ed25519"
|
|
};
|
|
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", addRequest);
|
|
|
|
// Act
|
|
var revokeRequest = new RevokeKeyRequestDto { Reason = "audit-test" };
|
|
var response = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/key-to-revoke/revoke",
|
|
revokeRequest);
|
|
|
|
// Assert
|
|
var result = await response.Content.ReadFromJsonAsync<RevokeKeyResponseDto>();
|
|
result!.AuditLogId.Should().NotBeNull("all key operations should create audit log entries");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Rotation Warnings Tests
|
|
|
|
[Fact]
|
|
public async Task GetRotationWarnings_ReturnsRelevantWarnings()
|
|
{
|
|
// Act
|
|
var response = await _client.GetAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/warnings");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
var warnings = await response.Content.ReadFromJsonAsync<RotationWarningsResponseDto>();
|
|
warnings.Should().NotBeNull();
|
|
warnings!.AnchorId.Should().Be(_testAnchorId);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Error Handling Tests
|
|
|
|
[Fact]
|
|
public async Task AddKey_DuplicateKeyId_Returns400()
|
|
{
|
|
// Arrange: add a key
|
|
var request = new AddKeyRequestDto
|
|
{
|
|
KeyId = "duplicate-key",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "Ed25519"
|
|
};
|
|
await _client.PostAsJsonAsync($"/api/v1/anchors/{_testAnchorId}/keys", request);
|
|
|
|
// Act: try to add same key again
|
|
var response = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys",
|
|
request);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeKey_NonexistentKey_Returns404()
|
|
{
|
|
// Arrange
|
|
var request = new RevokeKeyRequestDto { Reason = "test" };
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys/nonexistent-key/revoke",
|
|
request);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddKey_InvalidAlgorithm_Returns400()
|
|
{
|
|
// Arrange
|
|
var request = new AddKeyRequestDto
|
|
{
|
|
KeyId = "bad-algo-key",
|
|
PublicKey = TestKeys.Ed25519PublicKeyPem,
|
|
Algorithm = "UNKNOWN-ALG"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync(
|
|
$"/api/v1/anchors/{_testAnchorId}/keys",
|
|
request);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test key material.
|
|
/// </summary>
|
|
internal static class TestKeys
|
|
{
|
|
// Test Ed25519 public key (not for production use)
|
|
public const string Ed25519PublicKeyPem = """
|
|
-----BEGIN PUBLIC KEY-----
|
|
MCowBQYDK2VwAyEAGb9F2CMC7IaKG1svU1lN3Rjzk6uqO1l8dSEIAKDU8g0=
|
|
-----END PUBLIC KEY-----
|
|
""";
|
|
}
|