// ----------------------------------------------------------------------------- // 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; /// /// Integration tests for the complete key rotation workflow. /// Tests the full lifecycle: add key → transition period → revoke old key. /// public class KeyRotationWorkflowIntegrationTests : IClassFixture>, IAsyncLifetime { private readonly WebApplicationFactory _factory; private readonly HttpClient _client; private Guid _testAnchorId; public KeyRotationWorkflowIntegrationTests(WebApplicationFactory factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Use in-memory database for tests var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions)); if (descriptor != null) { services.Remove(descriptor); } services.AddDbContext(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(); _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(); 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( $"/api/v1/anchors/{_testAnchorId}/keys/initial-key/validity?signedAt={DateTimeOffset.UtcNow:O}"); var validity2 = await _client.GetFromJsonAsync( $"/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(); 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( $"/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( $"/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( $"/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(); 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(); 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(); 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 } /// /// Test key material. /// internal static class TestKeys { // Test Ed25519 public key (not for production use) public const string Ed25519PublicKeyPem = """ -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAGb9F2CMC7IaKG1svU1lN3Rjzk6uqO1l8dSEIAKDU8g0= -----END PUBLIC KEY----- """; }