// -----------------------------------------------------------------------------
// 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-----
""";
}